@stevecastaneda/paseo-progress 0.1.6 → 0.1.8

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.
@@ -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
- export function absolutePath(path: string, workspaceDirectory: string): string {
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 = workspaceDirectory.replace(/[\\/]+$/, "");
23
+ const base = root.replace(/[\\/]+$/, "");
23
24
  return path === "./" ? `${base}/` : `${base}/${path}`;
24
25
  }
25
26
 
package/client/panel.tsx CHANGED
@@ -10,7 +10,7 @@ import { raised } from "./surfaces";
10
10
  import { When } from "./when";
11
11
  import React, { useEffect, useRef, useState } from "react";
12
12
  import { ActivityIndicator, Animated, Platform, Pressable, ScrollView, Text, View } from "react-native";
13
- import { finishedAtTop, shortTitle, type Activity, type Dashboard, type FileIssue, type ProgressSegment, type StuckItem, type Ticket } from "../shared/dashboard";
13
+ import { finishedToFold, isFinished, shortTitle, type Activity, type Dashboard, type FileIssue, type ProgressSegment, type StuckItem, type Ticket } from "../shared/dashboard";
14
14
  import { PROGRESS_FILE, type TicketStatus } from "../shared/events";
15
15
  import { formatEstimate, formatMinutes, formatWorkDone, minutesSince } from "../shared/format";
16
16
  import { markPanelOpened } from "../shared/rpc";
@@ -54,7 +54,7 @@ function WorkspaceProgress({ theme, workspaceId, host, layout, navigation }: Plu
54
54
  <Presence show={Boolean(result && !result.configured)}>{result && !result.configured ? <EmptyState colors={colors} /> : null}</Presence>
55
55
  {/* Keyed by run: a new run starts with fresh tabs, pages and dialogs. */}
56
56
  {result?.configured ? <DashboardView key={result.dashboard.run?.startedAt ?? "none"} colors={colors} dashboard={result.dashboard} agentRunning={agentRunning} compact={layout.compact}
57
- workspaceId={workspaceId} workspaceDirectory={directory ?? ""} navigation={navigation} /> : null}
57
+ workspaceId={workspaceId} workspaceDirectory={result.root} navigation={navigation} /> : null}
58
58
  </ScrollView>
59
59
  );
60
60
  }
@@ -416,26 +416,25 @@ function StuckSection({ colors, items, now }: { colors: Colors; items: StuckItem
416
416
  // the app session like the history tab.
417
417
  const finishedShown = new Map<string, boolean>();
418
418
 
419
- // Finished tickets at the top fold into one row, so the work still ahead is
420
- // near the top without scrolling (see finishedAtTop).
419
+ // Finished tickets fold into one row at the top, so the work still ahead is
420
+ // near the top without scrolling (see finishedToFold).
421
421
  function TicketList({ colors, dashboard, live, now, workspaceId, onOpen }: { colors: Colors; dashboard: Dashboard; live: boolean; now: number; workspaceId: string; onOpen(id: string): void }) {
422
422
  const [shown, setShown] = useState(() => finishedShown.get(workspaceId) ?? false);
423
423
  const toggle = () => {
424
424
  finishedShown.set(workspaceId, !shown);
425
425
  setShown(!shown);
426
426
  };
427
- const folded = finishedAtTop(dashboard.tickets);
427
+ const finished = finishedToFold(dashboard.tickets);
428
428
  const row = (ticket: Ticket) => (
429
429
  <TicketRow key={ticket.id} colors={colors} ticket={ticket} live={live} now={now} onOpen={() => onOpen(ticket.id)}
430
430
  deliverables={dashboard.deliverables.filter((deliverable) => deliverable.ticketId === ticket.id).length} />
431
431
  );
432
- if (!folded) return <>{dashboard.tickets.map(row)}</>;
433
- const finished = dashboard.tickets.slice(0, folded);
432
+ if (!finished.length) return <>{dashboard.tickets.map(row)}</>;
434
433
  return (
435
434
  <>
436
435
  <FinishedRow colors={colors} tickets={finished} shown={shown} onPress={toggle} />
437
436
  {shown ? finished.map(row) : null}
438
- {dashboard.tickets.slice(folded).map(row)}
437
+ {dashboard.tickets.filter((ticket) => !isFinished(ticket)).map(row)}
439
438
  </>
440
439
  );
441
440
  }
@@ -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: directory ?? "", onPreview: (attachment) => setPreviewing({ attachment, group: previewable(group.current) }) });
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={directory ?? ""}
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 }}>
@@ -1,13 +1,13 @@
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 { existsSync, realpathSync, statSync } from "node:fs";
4
- import { dirname, join, relative, resolve } from "node:path";
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
8
  import { DELIVERABLE_KINDS, PROGRESS_FILE, 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
12
  import { dashboardText } from "../shared/show.js";
13
13
  import { imageMimeType } from "../shared/attachments.js";
@@ -436,19 +436,6 @@ function help() {
436
436
  ` ${INSTALL_SUMMARY}`,
437
437
  ].join("\n");
438
438
  }
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
439
  export async function runCli(argv, options) {
453
440
  const out = options.out ?? ((line) => console.log(line));
454
441
  const now = options.now ?? (() => new Date());
@@ -1,6 +1,22 @@
1
- import { isAbsolute, sep } from "node:path";
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
+ }
@@ -328,15 +328,17 @@ export function shortTitle(title) {
328
328
  const index = title.indexOf(":");
329
329
  return index > 0 && index <= 24 ? title.slice(0, index) : title;
330
330
  }
331
- // How many tickets at the top of the list are finished (done or skipped), so
332
- // the panel can fold them into one row and the work still ahead sits near the
333
- // top. Only the leading run folds: a ticket finished out of order stays in
334
- // place, so the plan's order never looks shuffled. A single one isn't worth a
335
- // fold row. Once every ticket is finished nothing is ahead, and the whole list
336
- // is the record of the run, so nothing folds.
337
- export function finishedAtTop(tickets) {
338
- const index = tickets.findIndex((ticket) => ticket.status !== "done" && ticket.status !== "skipped");
339
- return index >= 2 ? index : 0;
331
+ // The finished tickets (done or skipped) the panel folds into one row at the
332
+ // top, in plan order, so the work still ahead sits near the top. A ticket
333
+ // finished out of order folds too. A single one isn't worth a fold row. Once
334
+ // every ticket is finished nothing is ahead, and the whole list is the record
335
+ // of the run, so nothing folds.
336
+ export function finishedToFold(tickets) {
337
+ const finished = tickets.filter(isFinished);
338
+ return finished.length >= 2 && finished.length < tickets.length ? finished : [];
339
+ }
340
+ export function isFinished(ticket) {
341
+ return ticket.status === "done" || ticket.status === "skipped";
340
342
  }
341
343
  function plural(count, noun) {
342
344
  return `${count} ${count === 1 ? noun : `${noun}s`}`;
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@stevecastaneda/paseo-progress",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@stevecastaneda/paseo-progress",
9
- "version": "0.1.6",
9
+ "version": "0.1.8",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@getpaseo/client": "^0.9.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stevecastaneda/paseo-progress",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "A live progress dashboard per worktree for Paseo, drawn from a file agents write with the paseo-progress command.",
5
5
  "license": "MIT",
6
6
  "author": "Steve Castaneda",
package/server/cli.ts CHANGED
@@ -1,13 +1,13 @@
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 { existsSync, realpathSync, statSync } from "node:fs";
4
- import { dirname, join, relative, resolve } from "node:path";
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, type Dashboard, type NextIds } from "../shared/dashboard.ts";
8
8
  import { DELIVERABLE_KINDS, PROGRESS_FILE, TICKET_STATUSES } from "../shared/events.ts";
9
9
  import { appendProgress, InvalidEvent, readProgress, type EventDraft } from "./progress-file.ts";
10
- import { outside } from "./paths.ts";
10
+ import { findRoot, outside } from "./paths.ts";
11
11
  import { installLauncher } from "./launcher.ts";
12
12
  import { dashboardText } from "../shared/show.ts";
13
13
  import { imageMimeType } from "../shared/attachments.ts";
@@ -449,18 +449,6 @@ function help(): string {
449
449
  ].join("\n");
450
450
  }
451
451
 
452
- // The worktree root is the nearest directory with a `.git` entry (a folder in
453
- // a checkout, a file in a linked worktree). Outside git, use the directory.
454
- export function findRoot(cwd: string): string {
455
- let directory = resolve(cwd);
456
- while (true) {
457
- if (existsSync(join(directory, ".git"))) return directory;
458
- const parent = dirname(directory);
459
- if (parent === directory) return resolve(cwd);
460
- directory = parent;
461
- }
462
- }
463
-
464
452
  export async function runCli(argv: string[], options: CliOptions): Promise<number> {
465
453
  const out = options.out ?? ((line: string) => console.log(line));
466
454
  const now = options.now ?? (() => new Date());
@@ -3,6 +3,7 @@ import { createHash } from "node:crypto";
3
3
  import type { Dashboard } from "../shared/dashboard.ts";
4
4
  import type { AttentionResult, DashboardResult } from "../shared/rpc.ts";
5
5
  import { markPanelOpened, readProgress } from "./progress-file.ts";
6
+ import { findRoot } from "./paths.ts";
6
7
 
7
8
  const versions = new WeakMap<Dashboard, { panelOpened: boolean; version: string }>();
8
9
 
@@ -10,7 +11,7 @@ export async function handleMarkPanelOpened(
10
11
  input: { workspaceId: string; workspaceDirectory: string },
11
12
  _context: PluginHandlerContext,
12
13
  ) {
13
- await markPanelOpened(input.workspaceDirectory);
14
+ await markPanelOpened(findRoot(input.workspaceDirectory));
14
15
  return {};
15
16
  }
16
17
 
@@ -20,14 +21,15 @@ export async function handleGetDashboard(
20
21
  input: { workspaceId: string; workspaceDirectory: string; since?: string },
21
22
  _context: PluginHandlerContext,
22
23
  ): Promise<DashboardResult | { unchanged: true; version: string }> {
23
- const result = await readProgress(input.workspaceDirectory);
24
+ const root = findRoot(input.workspaceDirectory);
25
+ const result = await readProgress(root);
24
26
  let cached = versions.get(result.dashboard);
25
27
  if (!cached || cached.panelOpened !== result.panelOpened) {
26
28
  cached = { panelOpened: result.panelOpened, version: createHash("sha1").update(JSON.stringify(result)).digest("base64url") };
27
29
  versions.set(result.dashboard, cached);
28
30
  }
29
31
  const { version } = cached;
30
- return input.since === version ? { unchanged: true, version } : { ...result, version };
32
+ return input.since === version ? { unchanged: true, version } : { ...result, root, version };
31
33
  }
32
34
 
33
35
  // Just what a message-box pill needs, so polling every workspace stays small.
@@ -35,7 +37,7 @@ export async function handleGetAttention(
35
37
  input: { workspaceId: string; workspaceDirectory: string },
36
38
  _context: PluginHandlerContext,
37
39
  ): Promise<AttentionResult> {
38
- const { configured, dashboard, panelOpened } = await readProgress(input.workspaceDirectory);
40
+ const { configured, dashboard, panelOpened } = await readProgress(findRoot(input.workspaceDirectory));
39
41
  return {
40
42
  configured,
41
43
  questions: dashboard.questions.open.length,
package/server/open.ts CHANGED
@@ -6,7 +6,7 @@ import { join, relative, resolve } from "node:path";
6
6
  import { promisify } from "node:util";
7
7
  import { findAttachment, imageMimeType, previewKind } from "../shared/attachments.ts";
8
8
  import { readProgress } from "./progress-file.ts";
9
- import { outside } from "./paths.ts";
9
+ import { findRoot, outside } from "./paths.ts";
10
10
 
11
11
  export type Opener = (path: string) => Promise<void>;
12
12
 
@@ -43,10 +43,11 @@ export const systemOpener: Opener = async (path) => {
43
43
  // so the panel can never reach an arbitrary path. Only files inside the
44
44
  // worktree resolve.
45
45
  async function resolveAttachment(directory: string, ref: string, verb: string): Promise<{ path: string; target: string }> {
46
- const { dashboard } = await readProgress(directory);
46
+ const worktree = findRoot(directory);
47
+ const { dashboard } = await readProgress(worktree);
47
48
  const path = findAttachment(dashboard, ref)?.path;
48
49
  if (!path) throw new Error(`No local ${ref.startsWith("Q") ? "attachment" : "deliverable"} ${ref} in this worktree.`);
49
- const root = await realpath(directory);
50
+ const root = await realpath(worktree);
50
51
  const target = await realpath(resolve(root, path)).catch(() => null);
51
52
  if (!target) throw new Error(`${path} no longer exists.`);
52
53
  const inside = relative(root, target);
package/server/paths.ts CHANGED
@@ -1,7 +1,22 @@
1
- import { isAbsolute, sep } from "node:path";
1
+ import { existsSync } from "node:fs";
2
+ import { dirname, isAbsolute, join, resolve, sep } from "node:path";
2
3
 
3
4
  // Whether a path from relative(root, target) leaves the root. "..notes" is a
4
5
  // name inside it, not a step out.
5
6
  export function outside(inside: string): boolean {
6
7
  return inside === ".." || inside.startsWith(`..${sep}`) || isAbsolute(inside);
7
8
  }
9
+
10
+ // The worktree root is the nearest directory with a `.git` entry (a folder in
11
+ // a checkout, a file in a linked worktree). Outside git, use the directory.
12
+ // The command and the panel both start from here, so a Paseo project set at a
13
+ // subfolder of the repo still reads the file the agent writes.
14
+ export function findRoot(cwd: string): string {
15
+ let directory = resolve(cwd);
16
+ while (true) {
17
+ if (existsSync(join(directory, ".git"))) return directory;
18
+ const parent = dirname(directory);
19
+ if (parent === directory) return resolve(cwd);
20
+ directory = parent;
21
+ }
22
+ }
@@ -449,15 +449,18 @@ export function shortTitle(title: string): string {
449
449
  return index > 0 && index <= 24 ? title.slice(0, index) : title;
450
450
  }
451
451
 
452
- // How many tickets at the top of the list are finished (done or skipped), so
453
- // the panel can fold them into one row and the work still ahead sits near the
454
- // top. Only the leading run folds: a ticket finished out of order stays in
455
- // place, so the plan's order never looks shuffled. A single one isn't worth a
456
- // fold row. Once every ticket is finished nothing is ahead, and the whole list
457
- // is the record of the run, so nothing folds.
458
- export function finishedAtTop(tickets: Ticket[]): number {
459
- const index = tickets.findIndex((ticket) => ticket.status !== "done" && ticket.status !== "skipped");
460
- return index >= 2 ? index : 0;
452
+ // The finished tickets (done or skipped) the panel folds into one row at the
453
+ // top, in plan order, so the work still ahead sits near the top. A ticket
454
+ // finished out of order folds too. A single one isn't worth a fold row. Once
455
+ // every ticket is finished nothing is ahead, and the whole list is the record
456
+ // of the run, so nothing folds.
457
+ export function finishedToFold(tickets: Ticket[]): Ticket[] {
458
+ const finished = tickets.filter(isFinished);
459
+ return finished.length >= 2 && finished.length < tickets.length ? finished : [];
460
+ }
461
+
462
+ export function isFinished(ticket: Ticket): boolean {
463
+ return ticket.status === "done" || ticket.status === "skipped";
461
464
  }
462
465
 
463
466
  function plural(count: number, noun: string): string {
package/shared/rpc.ts CHANGED
@@ -3,8 +3,10 @@ import { z } from "zod";
3
3
  import type { Dashboard } from "./dashboard.ts";
4
4
 
5
5
  // `panelOpened`: the Progress panel has been opened for this worktree before.
6
+ // `root`: the worktree root the file was read from, which stored paths are
7
+ // relative to. It can sit above the workspace directory.
6
8
  // `version` changes whenever anything else in the result does.
7
- export type DashboardResult = { configured: boolean; dashboard: Dashboard; panelOpened: boolean; version: string };
9
+ export type DashboardResult = { configured: boolean; dashboard: Dashboard; panelOpened: boolean; root: string; version: string };
8
10
 
9
11
  export const getDashboard = defineRpc({
10
12
  name: "progress.get",