@stevecastaneda/paseo-progress 0.1.8 → 0.2.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
@@ -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 `.scratch/progress.jsonl` in the worktree. The **Progress** panel draws the dashboard from that file and updates by itself.
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 `.scratch/progress-panel-opened` 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.
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
- The plugin keeps each worktree's progress in `.scratch/progress.jsonl`. The first time it creates `.scratch/`, it also writes `.scratch/.gitignore` naming only its own files, so git ignores them without any change to your repository's `.gitignore`. If `.scratch/.gitignore` already exists, the plugin leaves it alone.
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
- `.scratch/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.
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 in `.scratch/progress.jsonl`. There's no view across worktrees.
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.
@@ -4,6 +4,7 @@ import React, { useEffect, useRef, useState } from "react";
4
4
  import type { Dashboard, Question } from "../shared/dashboard";
5
5
  import { type Attachment, attachmentIcon, useAttachmentOpener } from "./attachments";
6
6
  import { useLastPresent } from "./motion";
7
+ import { NarrowProvider } from "./narrow";
7
8
  import { BackButton, PreviewContent, previewable } from "./preview";
8
9
  import { QuestionView, useCopy } from "./questions";
9
10
  import { TicketStoryView } from "./ticket-dialog";
@@ -75,6 +76,8 @@ export function StackedDialog({ colors, root, onClose, questions, dashboard, now
75
76
  open={Boolean(root)} onOpenChange={(next) => { if (!next) onClose(); }}>
76
77
  {/* One Modal.Content for every step: replacing it would re-present the sheet on phones. */}
77
78
  <Modal.Content scrollable={!(preview && previewLong)}>
79
+ {/* A dialog has room for clock times, however narrow the panel that opened it. */}
80
+ <NarrowProvider value={false}>
78
81
  {preview ? (
79
82
  <PreviewContent colors={colors} attachment={preview.attachment} workspaceId={context.workspaceId} workspaceDirectory={context.workspaceDirectory}
80
83
  onOpenOnHost={() => { onClose(); void opener.openOnHost(preview.attachment); }} backLabel={back?.label} onBack={back?.onPress}
@@ -94,6 +97,7 @@ export function StackedDialog({ colors, root, onClose, questions, dashboard, now
94
97
  ) : null}
95
98
  </>
96
99
  )}
100
+ </NarrowProvider>
97
101
  </Modal.Content>
98
102
  </Modal>
99
103
  );
@@ -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
+ }
@@ -0,0 +1,13 @@
1
+ import { createContext, useContext } from "react";
2
+
3
+ // Paseo opens the Explorer sidebar at 320px and plugins can't ask for more,
4
+ // so at or below this width the panel trims what it shows to fit.
5
+ export const NARROW_MAX = 400;
6
+
7
+ const Narrow = createContext(false);
8
+
9
+ export const NarrowProvider = Narrow.Provider;
10
+
11
+ export function useNarrow(): boolean {
12
+ return useContext(Narrow);
13
+ }
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 { PROGRESS_FILE, type TicketStatus } from "../shared/events";
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";
@@ -20,6 +21,7 @@ import { TicketDialogs } from "./ticket-dialog";
20
21
  import { PressableRow } from "./row";
21
22
  import { ShowMoreRow, usePaged } from "./show-more";
22
23
  import { notePanelOpened } from "./panel-opened";
24
+ import { NARROW_MAX, NarrowProvider, useNarrow } from "./narrow";
23
25
 
24
26
  type Colors = PluginWorkspacePanelProps["theme"]["colors"];
25
27
 
@@ -39,9 +41,12 @@ function WorkspaceProgress({ theme, workspaceId, host, layout, navigation }: Plu
39
41
  const query = useDashboard(host.id, workspaceId, directory);
40
42
  const result = query.data ?? null;
41
43
  useMarkPanelOpened(workspaceId, directory, Boolean(result?.configured && !result.panelOpened));
44
+ const [narrow, setNarrow] = useState(false);
42
45
 
43
46
  return (
44
- <ScrollView style={{ flex: 1, backgroundColor: colors.surface0 }} contentContainerStyle={{ paddingBottom: 16 }}>
47
+ <NarrowProvider value={narrow}>
48
+ <ScrollView onLayout={(event) => setNarrow(event.nativeEvent.layout.width <= NARROW_MAX)}
49
+ style={{ flex: 1, backgroundColor: colors.surface0 }} contentContainerStyle={{ paddingBottom: 16 }}>
45
50
  {query.isPending ? <ActivityIndicator color={colors.foregroundMuted} accessibilityLabel="Loading progress" style={{ padding: 12 }} /> : null}
46
51
  {query.error ? (
47
52
  <Text accessibilityRole="alert" selectable style={{ color: colors.statusDanger, fontSize: 12, lineHeight: 18, padding: 12 }}>
@@ -51,11 +56,13 @@ function WorkspaceProgress({ theme, workspaceId, host, layout, navigation }: Plu
51
56
  <LauncherBanner colors={colors} host={host} />
52
57
  <SkillBanner colors={colors} host={host} />
53
58
  {/* 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>
59
+ <Presence show={Boolean(result && !result.configured)}>{result && !result.configured ? <EmptyState colors={colors} file={result.file} /> : null}</Presence>
55
60
  {/* Keyed by run: a new run starts with fresh tabs, pages and dialogs. */}
56
61
  {result?.configured ? <DashboardView key={result.dashboard.run?.startedAt ?? "none"} colors={colors} dashboard={result.dashboard} agentRunning={agentRunning} compact={layout.compact}
57
- workspaceId={workspaceId} workspaceDirectory={result.root} navigation={navigation} /> : null}
62
+ workspaceId={workspaceId} workspaceDirectory={result.root} file={result.file} navigation={navigation} /> : null}
63
+ {result?.inRepo && directory ? <HistoryRow colors={colors} workspaceId={workspaceId} directory={directory} savedTo={result.savedTo} /> : null}
58
64
  </ScrollView>
65
+ </NarrowProvider>
59
66
  );
60
67
  }
61
68
 
@@ -74,7 +81,7 @@ function useMarkPanelOpened(workspaceId: string, directory: string | null, neede
74
81
  }, [needed, directory, workspaceId, mark]);
75
82
  }
76
83
 
77
- function EmptyState({ colors }: { colors: Colors }) {
84
+ function EmptyState({ colors, file }: { colors: Colors; file: string }) {
78
85
  return (
79
86
  <View style={{ padding: 12, paddingTop: 20, gap: 10 }}>
80
87
  <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
@@ -91,9 +98,9 @@ function EmptyState({ colors }: { colors: Colors }) {
91
98
  {`paseo-progress start "Build the export feature"\npaseo-progress ticket add "Ticket 01: Export button" --estimate 60`}
92
99
  </Text>
93
100
  </View>
94
- {/* So a new .scratch folder in the repo isn't a surprise. */}
101
+ {/* So people know nothing lands in the repo. */}
95
102
  <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18 }}>
96
- Progress is saved in <Text style={{ color: colors.foreground }}>.scratch/progress.jsonl</Text> in this worktree. The plugin adds a <Text style={{ color: colors.foreground }}>.gitignore</Text> there, so git leaves its files alone.
103
+ Progress is saved outside the repo, in <Text selectable style={{ color: colors.foreground }}>{file}</Text>, so git never sees it.
97
104
  </Text>
98
105
  </View>
99
106
  );
@@ -122,13 +129,14 @@ function useNow(intervalMs: number): number {
122
129
 
123
130
  const WIDE_MIN = 760;
124
131
 
125
- function DashboardView({ colors, dashboard, agentRunning, compact, workspaceId, workspaceDirectory, navigation }: {
132
+ function DashboardView({ colors, dashboard, agentRunning, compact, workspaceId, workspaceDirectory, file, navigation }: {
126
133
  colors: Colors;
127
134
  dashboard: Dashboard;
128
135
  agentRunning: boolean;
129
136
  compact: boolean;
130
137
  workspaceId: string;
131
138
  workspaceDirectory: string;
139
+ file: string;
132
140
  navigation: PluginWorkspacePanelProps["navigation"];
133
141
  }) {
134
142
  const now = useNow(15_000);
@@ -198,7 +206,7 @@ function DashboardView({ colors, dashboard, agentRunning, compact, workspaceId,
198
206
  </Text>
199
207
  </View>
200
208
  ) : null}</Presence>
201
- <Presence show={Boolean(dashboard.issues.length)} order={1}>{dashboard.issues.length ? <IssuesNotice colors={colors} issues={dashboard.issues} /> : null}</Presence>
209
+ <Presence show={Boolean(dashboard.issues.length)} order={1}>{dashboard.issues.length ? <IssuesNotice colors={colors} issues={dashboard.issues} file={file} /> : null}</Presence>
202
210
  <Presence show={Boolean(dashboard.progress.totalMin > 0)} order={2}>{dashboard.progress.totalMin > 0 ? <ProgressBar colors={colors} progress={dashboard.progress} live={live} /> : null}</Presence>
203
211
  <View onLayout={(event) => setWide(event.nativeEvent.layout.width >= WIDE_MIN)}
204
212
  style={{ flexDirection: wide ? "row" : "column", alignItems: "flex-start" }}>
@@ -253,6 +261,7 @@ const historyTabs = new Map<string, HistoryTab>();
253
261
  function HistoryCard({ colors, workspaceId, tabs }: { colors: Colors; workspaceId: string; tabs: HistoryTabEntry[] }) {
254
262
  const [tab, setTab] = useState<HistoryTab>(() => historyTabs.get(workspaceId) ?? "activity");
255
263
  const [showIcons, setShowIcons] = useState(true);
264
+ const narrow = useNarrow();
256
265
  const choose = (next: HistoryTab) => {
257
266
  historyTabs.set(workspaceId, next);
258
267
  setTab(next);
@@ -261,9 +270,9 @@ function HistoryCard({ colors, workspaceId, tabs }: { colors: Colors; workspaceI
261
270
  return (
262
271
  <View onLayout={(event) => setShowIcons(event.nativeEvent.layout.width >= TAB_ICONS_MIN)}
263
272
  style={{ margin: 12, marginBottom: 0, ...raised(colors), borderRadius: 6, overflow: "hidden" }}>
264
- <View accessibilityRole="tablist" style={{ flexDirection: "row", flexWrap: "wrap", columnGap: 16, paddingHorizontal: 10, borderBottomWidth: 1, borderBottomColor: colors.border }}>
273
+ <View accessibilityRole="tablist" style={{ flexDirection: "row", flexWrap: "wrap", columnGap: narrow ? 10 : 16, paddingHorizontal: narrow ? 8 : 10, borderBottomWidth: 1, borderBottomColor: colors.border }}>
265
274
  {tabs.map((entry) => {
266
- return <Tab key={entry.id} colors={colors} label={entry.label} icon={showIcons ? entry.icon : null} count={entry.count} selected={entry.id === current.id} onPress={() => choose(entry.id)} />;
275
+ return <Tab key={entry.id} colors={colors} narrow={narrow} label={entry.label} icon={showIcons ? entry.icon : null} count={entry.count} selected={entry.id === current.id} onPress={() => choose(entry.id)} />;
267
276
  })}
268
277
  </View>
269
278
  {current.count === 0 ? (
@@ -274,13 +283,14 @@ function HistoryCard({ colors, workspaceId, tabs }: { colors: Colors; workspaceI
274
283
  }
275
284
 
276
285
  // Instant feedback: tabs switch often, so hover and press change color only.
277
- function Tab({ colors, label, icon, count, selected, onPress }: { colors: Colors; label: string; icon: string | null; count: number; selected: boolean; onPress(): void }) {
286
+ // Narrow tightens the spacing so all three tabs stay on one line at the sidebar's 320px.
287
+ function Tab({ colors, narrow, label, icon, count, selected, onPress }: { colors: Colors; narrow: boolean; label: string; icon: string | null; count: number; selected: boolean; onPress(): void }) {
278
288
  const [hovered, setHovered] = useState(false);
279
289
  return (
280
290
  <Pressable accessibilityRole="tab" accessibilityState={{ selected }} onPress={onPress}
281
291
  onHoverIn={() => setHovered(true)} onHoverOut={() => setHovered(false)}
282
292
  style={({ pressed }) => ({
283
- flexDirection: "row", alignItems: "center", gap: 6, paddingTop: 8, paddingBottom: 6, marginBottom: -1,
293
+ flexDirection: "row", alignItems: "center", gap: narrow ? 4 : 6, paddingTop: 8, paddingBottom: 6, marginBottom: -1,
284
294
  borderBottomWidth: 2,
285
295
  borderBottomColor: selected ? colors.accent : pressed || hovered ? colors.border : "transparent",
286
296
  })}>
@@ -314,13 +324,13 @@ function ActivityList({ colors, activity, now }: { colors: Colors; activity: Act
314
324
  }
315
325
 
316
326
  // Small and non-blocking: the rest of the dashboard still renders.
317
- function IssuesNotice({ colors, issues }: { colors: Colors; issues: FileIssue[] }) {
327
+ function IssuesNotice({ colors, issues, file }: { colors: Colors; issues: FileIssue[]; file: string }) {
318
328
  const shown = issues.slice(0, 5);
319
329
  return (
320
330
  <View style={{ flexDirection: "row", gap: 8, margin: 12, marginBottom: 0, padding: 8, borderRadius: 6, backgroundColor: colors.surface1 }}>
321
331
  <View style={{ paddingTop: 1.5 }}><Icon name="FileWarning" size={14} color={colors.statusWarning} /></View>
322
332
  <Text selectable style={{ flex: 1, color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>
323
- Skipped {issues.length === 1 ? "1 line" : `${issues.length} lines`} in {PROGRESS_FILE}:{" "}
333
+ Skipped {issues.length === 1 ? "1 line" : `${issues.length} lines`} in {file}:{" "}
324
334
  {shown.map((issue) => `line ${issue.line} (${issue.reason})`).join(", ")}
325
335
  {issues.length > shown.length ? `, and ${issues.length - shown.length} more` : ""}.
326
336
  </Text>
@@ -338,11 +348,12 @@ function Card({ colors, icon, title, children }: { colors: Colors; icon: string;
338
348
  }
339
349
 
340
350
  function ProgressBar({ colors, progress, live }: { colors: Colors; progress: Dashboard["progress"]; live: boolean }) {
351
+ const narrow = useNarrow();
341
352
  return (
342
353
  <View style={{ paddingHorizontal: 12, paddingTop: 12, paddingBottom: 12, gap: 8, borderBottomWidth: 1, borderBottomColor: colors.border }}>
343
354
  <View style={{ flexDirection: "row", alignItems: "baseline", gap: 8 }}>
344
355
  <Text style={{ flex: 1, color: colors.foregroundMuted, fontSize: 13, lineHeight: 18 }}>
345
- <Text style={{ color: colors.foreground, fontWeight: "600" }}>{progress.percent}%</Text> of estimated work done
356
+ <Text style={{ color: colors.foreground, fontWeight: "600" }}>{progress.percent}%</Text> {narrow ? "done" : "of estimated work done"}
346
357
  </Text>
347
358
  <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18 }}>
348
359
  {formatWorkDone(progress.doneMin, progress.totalMin, progress.tookMin)}
@@ -390,6 +401,7 @@ function Shimmer() {
390
401
  const STUCK_ICON: Record<StuckItem["kind"], string> = { blocked: "Ban", overdue: "Hourglass", manual: "Flag" };
391
402
 
392
403
  function StuckSection({ colors, items, now }: { colors: Colors; items: StuckItem[]; now: number }) {
404
+ const narrow = useNarrow();
393
405
  return (
394
406
  <View style={{ margin: 12, marginBottom: 0, borderWidth: 1, borderColor: colors.statusDanger, borderRadius: 6, overflow: "hidden" }}>
395
407
  <SectionTitle colors={colors} icon="OctagonAlert" title="Stuck" color={colors.statusDanger} style={{ borderBottomWidth: 1, borderBottomColor: colors.border }} />
@@ -401,7 +413,7 @@ function StuckSection({ colors, items, now }: { colors: Colors; items: StuckItem
401
413
  <Text style={{ color: colors.foreground, fontSize: 13, lineHeight: 18 }}>{item.title}</Text>
402
414
  <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>
403
415
  {item.kind === "overdue" ? `No update for longer than its ${formatMinutes(item.estimateMin)} estimate. Last update ` : null}
404
- {item.kind === "blocked" ? `Blocked${item.note ? `: ${item.note}` : ""}. Since ` : null}
416
+ {item.kind === "blocked" ? `Blocked${item.note ? `: ${item.note}` : ""}${narrow ? ", " : ". Since "}` : null}
405
417
  {item.kind === "manual" ? `${item.ticketId ? `${item.reason}. ` : ""}Flagged ` : null}
406
418
  <When colors={colors} iso={item.since} now={now} />
407
419
  </Text>
@@ -447,6 +459,7 @@ function FinishedRow({ colors, tickets, shown, onPress }: { colors: Colors; tick
447
459
  const label = [done ? `${done} done` : "", skipped ? `${skipped} skipped` : ""].filter(Boolean).join(", ");
448
460
  const estimateMin = tickets.reduce((sum, ticket) => sum + (ticket.status === "done" ? ticket.estimateMin : 0), 0);
449
461
  const chevron = shown ? "ChevronUp" : "ChevronDown";
462
+ const narrow = useNarrow();
450
463
  return (
451
464
  <PressableRow colors={colors} accessibilityRole="button" accessibilityState={{ expanded: shown }}
452
465
  accessibilityLabel={`${shown ? "Hide" : "Show"} ${label}`} onPress={onPress}
@@ -455,8 +468,11 @@ function FinishedRow({ colors, tickets, shown, onPress }: { colors: Colors; tick
455
468
  <Icon name={done ? "Check" : "CircleSlash"} size={14} color={done ? colors.statusSuccess : colors.foregroundMuted} />
456
469
  </View>
457
470
  <Text style={{ flex: 1, minWidth: 0, color: colors.foregroundMuted, fontSize: 12, lineHeight: 18, fontWeight: "600", fontVariant: ["tabular-nums"] }}>{label}</Text>
458
- <View style={{ width: 12 }} />
459
- <Text style={{ minWidth: 40, flexShrink: 0, textAlign: "right", color: colors.foregroundMuted, fontSize: 12, lineHeight: 18, fontVariant: ["tabular-nums"] }}>{estimateMin ? formatEstimate(estimateMin) : ""}</Text>
471
+ {/* Narrow ticket rows drop their chevron, so this total would sit off their estimate column. */}
472
+ {narrow ? null : <>
473
+ <View style={{ width: 12 }} />
474
+ <Text style={{ minWidth: 40, flexShrink: 0, textAlign: "right", color: colors.foregroundMuted, fontSize: 12, lineHeight: 18, fontVariant: ["tabular-nums"] }}>{estimateMin ? formatEstimate(estimateMin) : ""}</Text>
475
+ </>}
460
476
  <View style={{ paddingTop: 2 }}>
461
477
  <IconSwap swapKey={chevron} size={14}><Icon name={chevron} size={14} color={colors.foregroundMuted} /></IconSwap>
462
478
  </View>
@@ -468,12 +484,14 @@ function FinishedRow({ colors, tickets, shown, onPress }: { colors: Colors; tick
468
484
  function TicketRow({ colors, ticket, live, now, onOpen, deliverables }: { colors: Colors; ticket: Ticket; live: boolean; now: number; onOpen(): void; deliverables: number }) {
469
485
  const muted = ticket.status === "skipped";
470
486
  const working = ticket.status === "working";
487
+ // Narrow: tighter gaps and no chevron (the whole row still presses), so the title keeps its width.
488
+ const narrow = useNarrow();
471
489
  return (
472
490
  <PressableRow colors={colors} onSurface1={working} transparentAtRest={!working}
473
491
  accessibilityRole="button" accessibilityLabel={`${ticket.id} details`} onPress={onOpen}
474
- style={{ flexDirection: "row", alignItems: "flex-start", gap: 10, paddingHorizontal: 10, paddingVertical: 8, borderTopWidth: 1, borderTopColor: colors.border }}>
492
+ style={{ flexDirection: "row", alignItems: "flex-start", gap: narrow ? 6 : 10, paddingHorizontal: 10, paddingVertical: 8, borderTopWidth: 1, borderTopColor: colors.border }}>
475
493
  {/* The icon is the only status mark on the row; the ticket dialog spells it out in a badge. */}
476
- <View accessible accessibilityLabel={ticket.waitingFor ? "Waiting" : STATUS_LABEL[ticket.status]} style={{ width: 16, paddingTop: 2, alignItems: "center" }}>
494
+ <View accessible accessibilityLabel={ticket.waitingFor ? "Waiting" : STATUS_LABEL[ticket.status]} style={{ width: 16, paddingTop: 2, alignItems: "center", marginRight: narrow ? 2 : 0 }}>
477
495
  <IconSwap swapKey={ticket.waitingFor || ticket.status === "not_started" ? "waiting" : ticket.status === "working" ? `working-${live}` : ticket.status} size={14}><StatusIcon colors={colors} status={ticket.status} live={live} waiting={Boolean(ticket.waitingFor)} /></IconSwap>
478
496
  </View>
479
497
  <View style={{ flex: 1, minWidth: 0, gap: 2 }}>
@@ -487,7 +505,7 @@ function TicketRow({ colors, ticket, live, now, onOpen, deliverables }: { colors
487
505
  ) : null}
488
506
  {ticket.status === "working" && ticket.workingSince ? (
489
507
  <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>
490
- Working since <When colors={colors} iso={ticket.workingSince} now={now} />
508
+ {narrow ? "Started working " : "Working since "}<When colors={colors} iso={ticket.workingSince} now={now} />
491
509
  </Text>
492
510
  ) : null}
493
511
  {ticket.waitingFor ? (
@@ -500,7 +518,7 @@ function TicketRow({ colors, ticket, live, now, onOpen, deliverables }: { colors
500
518
  {deliverables ? <Icon name="Package" size={12} color={colors.foregroundMuted} /> : null}
501
519
  </View>
502
520
  <Text style={{ minWidth: 40, flexShrink: 0, textAlign: "right", color: colors.foregroundMuted, fontSize: 12, lineHeight: 18, fontVariant: ["tabular-nums"] }}>{formatEstimate(ticket.estimateMin)}</Text>
503
- <View style={{ paddingTop: 2 }}><Icon name="ChevronRight" size={14} color={colors.foregroundMuted} /></View>
521
+ {narrow ? null : <View style={{ paddingTop: 2 }}><Icon name="ChevronRight" size={14} color={colors.foregroundMuted} /></View>}
504
522
  </PressableRow>
505
523
  );
506
524
  }
@@ -8,6 +8,7 @@ import { PressableRow } from "./row";
8
8
  import { ShowMoreRow, usePaged } from "./show-more";
9
9
  import { PressScale } from "./motion";
10
10
  import { StackedDialog } from "./dialog-stack";
11
+ import { useNarrow } from "./narrow";
11
12
  import { raised } from "./surfaces";
12
13
  import { When } from "./when";
13
14
 
@@ -140,6 +141,9 @@ function QuestionRow({ colors, question, first, onOpen, onCopy }: {
140
141
  onOpen(): void;
141
142
  onCopy(): void;
142
143
  }) {
144
+ // Narrow: the badge goes under the question, so the question keeps the row's width.
145
+ const narrow = useNarrow();
146
+ const badge = <DefaultBadge colors={colors} value={question.default} waits={question.waits} />;
143
147
  return (
144
148
  <View style={{ flexDirection: "row", gap: 8, paddingHorizontal: 10, paddingVertical: 10, borderTopWidth: first ? 0 : 1, borderTopColor: colors.border }}>
145
149
  <PressScale accessibilityRole="button" accessibilityLabel={`Copy ${question.id} for your reply`} onPress={onCopy} hitSlop={6}
@@ -154,8 +158,11 @@ function QuestionRow({ colors, question, first, onOpen, onCopy }: {
154
158
  </PressScale>
155
159
  <PressableRow colors={colors} accessibilityRole="button" accessibilityLabel={`${question.id} choices`} onPress={onOpen}
156
160
  style={{ flex: 1, minWidth: 0, flexDirection: "row", alignItems: "flex-start", gap: 8, borderRadius: 4, margin: -4, padding: 4 }}>
157
- <Text style={{ flex: 1, color: colors.foreground, fontSize: 13, lineHeight: 19 }}>{question.question}</Text>
158
- <DefaultBadge colors={colors} value={question.default} waits={question.waits} />
161
+ <View style={{ flex: 1, minWidth: 0, gap: 6, alignItems: "flex-start" }}>
162
+ <Text style={{ alignSelf: "stretch", color: colors.foreground, fontSize: 13, lineHeight: 19 }}>{question.question}</Text>
163
+ {narrow ? badge : null}
164
+ </View>
165
+ {narrow ? null : badge}
159
166
  {/* Optical: centers the 14px chevron on the 19px first line. */}
160
167
  <View style={{ paddingTop: 2.5 }}><Icon name="ChevronRight" size={14} color={colors.foregroundMuted} /></View>
161
168
  </PressableRow>
package/client/when.tsx CHANGED
@@ -2,11 +2,14 @@ import type { PluginWorkspacePanelProps } from "@getpaseo/plugin/client";
2
2
  import React from "react";
3
3
  import { Text } from "react-native";
4
4
  import { formatAgo, formatClock } from "../shared/format";
5
+ import { useNarrow } from "./narrow";
5
6
 
6
7
  type Colors = PluginWorkspacePanelProps["theme"]["colors"];
7
8
 
8
- // "Sep 27, 8:14 PM (10 h 2 min ago)", inline inside other text.
9
+ // "Sep 27, 8:14 PM (10 h 2 min ago)", inline inside other text. A narrow
10
+ // panel shows only "10 h 2 min ago"; the ticket dialog keeps the clock time.
9
11
  export function When({ colors, iso, now }: { colors: Colors; iso: string; now: number }) {
12
+ if (useNarrow()) return <Text>{formatAgo(iso, now)}</Text>;
10
13
  return (
11
14
  <Text>
12
15
  {formatClock(iso)} <Text style={{ color: colors.foregroundMuted, opacity: 0.7 }}>({formatAgo(iso, now)})</Text>
@@ -5,10 +5,11 @@ 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, PROGRESS_FILE, TICKET_STATUSES } from "../shared/events.js";
8
+ import { DELIVERABLE_KINDS, TICKET_STATUSES } from "../shared/events.js";
9
9
  import { appendProgress, InvalidEvent, readProgress } from "./progress-file.js";
10
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 in ${PROGRESS_FILE} at the worktree root, shown by the Progress panel in Paseo.`,
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}`]),
@@ -451,7 +452,8 @@ export async function runCli(argv, options) {
451
452
  }
452
453
  if (first === "show") {
453
454
  const root = findRoot(options.cwd);
454
- out(`Worktree: ${root}\n\n${dashboardText((await readProgress(root, now())).dashboard, now().getTime())}`);
455
+ const store = await findStore(root);
456
+ out(`Worktree: ${root}\n\n${dashboardText((await readProgress(store.directory, now())).dashboard, now().getTime())}`);
455
457
  return 0;
456
458
  }
457
459
  const target = resolve(options.cwd, (argv[1] ?? DEFAULT_LAUNCHER).replace(/^~(?=$|\/)/, homedir()));
@@ -478,14 +480,15 @@ export async function runCli(argv, options) {
478
480
  throw new UsageError(error.message);
479
481
  }
480
482
  const root = findRoot(options.cwd);
481
- const message = await appendProgress(root, now, (state) => {
483
+ const store = await findStore(root);
484
+ const message = await appendProgress(store.directory, now, (state) => {
482
485
  const finished = state.dashboard.run?.finished;
483
486
  if (finished && command !== commands.start) {
484
487
  throw new UsageError(`The run "${state.dashboard.run.title}" is finished. Start new work with: ${USAGE_NAME} start "<title>"`);
485
488
  }
486
489
  const { event, message } = command.run({ positionals: parsed.positionals, values: parsed.values, state, cwd: options.cwd, root });
487
490
  return { event, result: message };
488
- });
491
+ }, (text) => saveRunCopy(root, text));
489
492
  out(message);
490
493
  return 0;
491
494
  }