Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions .claude/skills/run-desktop/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
---
name: run-desktop
description: Launch the Ateam desktop (Electron) app and drive its UI — hover, click, screenshot, query the DOM — against a throwaway data dir seeded with demo tasks. Use when a change needs to be seen working in the real app, when asked to run/start/screenshot Ateam, or to verify hover/scroll/list states that only appear with a full board.
---

# Run and drive the Ateam desktop app

Verifies a renderer change in the running app without touching the developer's
real data or the dev instance another worktree may already have open.

## Inputs

- `data-dir` — throwaway user-data dir (use the session scratchpad, not `/tmp`)
- `cdp-port` — DevTools port, default `9333`
- `count` — how many demo tasks to seed, default `45`

## Procedure

1. **Launch** (background — the process stays up; never pipe it through `tail`,
which buffers stdout and hides startup errors):

```bash
bash .claude/skills/run-desktop/launch.sh "$SCRATCH/udata" 9333
```

It installs, rebuilds native modules if their arch is wrong, builds, and
launches with `--user-data-dir` + `--remote-debugging-port`.

2. **Wait for the window** — the browser answers on the port before any
BrowserWindow exists, so poll for a *page* target, not just the port:

```bash
until curl -s http://127.0.0.1:9333/json/list | grep -q '"type": "page"'; do sleep 2; done
```

No page target after ~30s means main crashed — read the launch output file.

3. **Seed, if the UI needs a populated board** (empty state otherwise):

```bash
bash .claude/skills/run-desktop/seed-tasks.sh "$SCRATCH/udata/ateam.sqlite" 45
```

The app must have run once to create the schema. Seed, then `reload` in the
next step — the renderer refetches projects and tasks on load.

4. **Drive it and look at what comes back:**

```bash
bun .claude/skills/run-desktop/drive.ts --out "$SCRATCH" \
reload \
'eval:(() => { const e = document.querySelector(".sidebar"); return { overflows: e.scrollHeight > e.clientHeight }; })()' \
rest 'shot:rest@.sidebar' \
hover:.sidebar 'shot:hover@.sidebar'
```

Read the PNGs. A blank frame is a failed launch, not a passing test. For
hover-only styling, also assert the computed value rather than trusting the
image: `eval:getComputedStyle(document.querySelector(".sidebar")).scrollbarColor`.

5. **Stop it by data dir, never by app name** — `pkill -f "Ateam"` kills the
developer's production app and any sibling worktree's dev instance:

```bash
pkill -f "user-data-dir=$SCRATCH/udata"
```

## Gotchas this encodes

- **A dev instance may already be running** from a sibling worktree, sharing
`~/Library/Application Support/@ateam/desktop`. The isolated `--user-data-dir`
is what keeps both that session and the real data out of the blast radius.
There is no single-instance lock, so a second instance starts fine.
- **`bun install` in a fresh worktree can fetch the wrong-arch `better-sqlite3`**
and the app dies at startup with `ERR_DLOPEN_FAILED`. `launch.sh` checks and
runs `bun run rebuild` (electron-rebuild against Electron's ABI).
- **Seeded tasks point at paths that don't exist.** Deliberate: the board
reconciler's `git`/`gh` probes throw and are swallowed, so seeding stays
offline and touches no real repo.
- **Screenshots alone can't prove `:hover`.** The macOS pointer is elsewhere;
CDP's `Input.dispatchMouseEvent` is what actually sets the hover state.
113 changes: 113 additions & 0 deletions .claude/skills/run-desktop/drive.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// Drive the running desktop app's renderer over the Chrome DevTools Protocol.
// CDP's synthetic mouse is what makes CSS :hover fire in Blink, so this can
// verify hover-only styling that a plain screenshot never reaches.
//
// bun .claude/skills/run-desktop/drive.ts --out <dir> [--port 9333] <step>...
//
// Steps run in order:
// reload reload the renderer (after seeding the database)
// rest park the pointer off in the far corner (no hover)
// hover:<selector> move the pointer to the element's centre
// click:<selector> hover, then press and release
// shot:<name> screenshot the whole window
// shot:<name>@<sel> screenshot cropped to that element, 3x, for fine detail
// eval:<expression> evaluate in the page and print the JSON result
const args = process.argv.slice(2);
const opt = (flag: string, fallback?: string) => {
const i = args.indexOf(flag);
return i === -1 ? fallback : args[i + 1];
};
const PORT = Number(opt("--port", "9333"));
const OUT = opt("--out", ".") as string;
const steps = args.filter((a, i) => !a.startsWith("--") && !args[i - 1]?.startsWith("--"));

const targets = (await (await fetch(`http://127.0.0.1:${PORT}/json/list`)).json()) as Array<{
type: string;
title: string;
webSocketDebuggerUrl: string;
}>;
const page = targets.find((t) => t.type === "page");
if (!page) throw new Error(`no page target on :${PORT} — is the app up? ${JSON.stringify(targets)}`);
console.log("target:", page.title);

const ws = new WebSocket(page.webSocketDebuggerUrl);
await new Promise((r) => ws.addEventListener("open", r, { once: true }));
let id = 0;
const pending = new Map<number, (v: any) => void>();
ws.addEventListener("message", (e) => {
const msg = JSON.parse(String(e.data));
if (msg.id && pending.has(msg.id)) {
pending.get(msg.id)?.(msg);
pending.delete(msg.id);
}
});
const send = (method: string, params: Record<string, unknown> = {}): Promise<any> => {
const myId = ++id;
return new Promise((resolve) => {
pending.set(myId, resolve);
ws.send(JSON.stringify({ id: myId, method, params }));
});
};
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));

async function evaluate(expression: string) {
const r = await send("Runtime.evaluate", { expression, returnByValue: true });
if (r.result?.exceptionDetails) throw new Error(r.result.exceptionDetails.text);
return r.result?.result?.value;
}
async function rect(selector: string) {
const r = await evaluate(`(() => {
const el = document.querySelector(${JSON.stringify(selector)});
if (!el) return null;
const b = el.getBoundingClientRect();
return { x: b.x, y: b.y, w: b.width, h: b.height };
})()`);
if (!r) throw new Error(`no element matches ${selector}`);
return r as { x: number; y: number; w: number; h: number };
}
const mouse = (type: string, x: number, y: number, extra: Record<string, unknown> = {}) =>
send("Input.dispatchMouseEvent", { type, x, y, buttons: 0, ...extra });

for (const step of steps) {
const split = step.indexOf(":");
const verb = split === -1 ? step : step.slice(0, split);
const rest = split === -1 ? "" : step.slice(split + 1);
if (step === "reload") {
await send("Page.reload");
await wait(3000);
} else if (step === "rest") {
// Far corner of the viewport: nothing under the pointer, so no :hover.
const vp = await evaluate("({ w: innerWidth, h: innerHeight })");
await mouse("mouseMoved", vp.w - 2, vp.h - 2);
await wait(300);
} else if (verb === "hover" || verb === "click") {
const b = await rect(rest);
const [x, y] = [b.x + b.w / 2, b.y + b.h / 2];
await mouse("mouseMoved", x, y);
await wait(300);
if (verb === "click") {
await mouse("mousePressed", x, y, { button: "left", clickCount: 1, buttons: 1 });
await mouse("mouseReleased", x, y, { button: "left", clickCount: 1 });
await wait(500);
}
} else if (verb === "shot") {
const [name, selector] = rest.split("@");
let clip: Record<string, number> | undefined;
if (selector) {
const b = await rect(selector);
clip = { x: b.x, y: b.y, width: b.w, height: b.h, scale: 3 };
}
const r = await send("Page.captureScreenshot", {
format: "png",
...(clip ? { clip, captureBeyondViewport: false } : {}),
});
if (!r.result?.data) throw new Error(`screenshot failed: ${JSON.stringify(r)}`);
await Bun.write(`${OUT}/${name}.png`, Buffer.from(r.result.data, "base64"));
console.log("wrote", `${OUT}/${name}.png`);
} else if (verb === "eval") {
console.log(step, "=>", JSON.stringify(await evaluate(rest)));
} else {
throw new Error(`unknown step: ${step}`);
}
}
ws.close();
33 changes: 33 additions & 0 deletions .claude/skills/run-desktop/launch.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
#!/bin/bash
# Build and launch the Ateam desktop app against a throwaway user-data dir with
# CDP enabled, so an agent can drive it without touching the developer's real
# app data (or whatever dev instance is already running from another worktree).
#
# bash .claude/skills/run-desktop/launch.sh <data-dir> [cdp-port]
#
# Run it in the BACKGROUND and read its output file — do NOT pipe it through
# `tail`, which buffers stdout and swallows the startup error you need.
set -e

DATA_DIR="${1:?usage: launch.sh <data-dir> [cdp-port]}"
PORT="${2:-9333}"
ROOT="$(git rev-parse --show-toplevel)"
mkdir -p "$DATA_DIR"

cd "$ROOT"
[ -d node_modules ] || bun install

# A fresh `bun install` can land the x86_64 prebuild of better-sqlite3 even on
# Apple silicon; the app then dies at startup with ERR_DLOPEN_FAILED. Rebuild
# the native modules against Electron's ABI whenever the arch doesn't match.
NATIVE=$(echo node_modules/.bun/better-sqlite3@*/node_modules/better-sqlite3/build/Release/better_sqlite3.node)
if [ ! -f "$NATIVE" ] || ! file "$NATIVE" | grep -q "$(uname -m)"; then
echo "[launch] native modules are not $(uname -m) — rebuilding for Electron"
(cd apps/desktop && bun run rebuild)
fi

(cd apps/desktop && bun run build)

echo "[launch] user-data-dir=$DATA_DIR cdp=$PORT"
cd apps/desktop
exec bunx electron . --user-data-dir="$DATA_DIR" --remote-debugging-port="$PORT"
41 changes: 41 additions & 0 deletions .claude/skills/run-desktop/seed-tasks.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
#!/bin/bash
# Fill a throwaway Ateam database with a demo project and N tasks, so list-shaped
# UI (sidebar overflow, board columns, agent-status dots) can be seen without
# creating real worktrees.
#
# bash .claude/skills/run-desktop/seed-tasks.sh <data-dir>/ateam.sqlite [count]
#
# Launch the app ONCE first so it creates the schema, quit it, then seed and
# relaunch (or reload the renderer over CDP). Worktree paths are fake on purpose:
# the board reconciler's git/gh probes throw and are swallowed, so seeding costs
# no network and touches no real repo.
set -e

DB="${1:?usage: seed-tasks.sh <path/to/ateam.sqlite> [count]}"
COUNT="${2:-45}"
[ -f "$DB" ] || { echo "no db at $DB — launch the app once to create it"; exit 1; }

PID=$(sqlite3 "$DB" "select id from projects limit 1;")
NOW=$(python3 -c 'import time;print(int(time.time()*1000))')
if [ -z "$PID" ]; then
PID=$(uuidgen)
sqlite3 "$DB" "insert into projects (id,repo_path,name,default_branch,worktrees_root,color,last_opened_at,created_at)
values ('$PID','/tmp/ateam-demo','demo','main','/tmp/ateam-demo/.wt','#6ee7b7',$NOW,$NOW);"
fi

# Cycle columns and agent statuses so every visual state is represented.
COLS=(todo running review merged todo running review)
STATUSES=("idle" "running" "needs_attention" "awaiting_input" "review" "stopped" "")

i=0
while [ "$i" -lt "$COUNT" ]; do
slug="seeded-task-$i"
col=${COLS[$((i % 7))]}
st=${STATUSES[$((i % 7))]}
sqlite3 "$DB" "insert into tasks (id,project_id,name,slug,branch,base_branch,worktree_path,\"column\",agent_status,agent_id,created_by,created_at,updated_at,last_event_at)
values ('$(uuidgen)','$PID','seeded task $i','$slug','$slug','main','/tmp/ateam-demo/.wt/$slug','$col',
$([ -z "$st" ] && echo NULL || echo "'$st'"),'claude','ateam',$NOW,$NOW,$((NOW - i * 1000)));"
i=$((i + 1))
done

echo "seeded $COUNT tasks into project $PID (total: $(sqlite3 "$DB" 'select count(*) from tasks;'))"
19 changes: 19 additions & 0 deletions apps/desktop/src/renderer/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -1559,6 +1559,25 @@ input {
::-webkit-scrollbar-corner {
background: transparent;
}
/* Sidebar: thumb stays invisible until the pointer is over the sidebar. The
track keeps its width either way, so revealing it never shifts the list. */
.sidebar {
scrollbar-color: transparent transparent;
}
.sidebar::-webkit-scrollbar-thumb {
background: transparent;
}
.sidebar:hover {
scrollbar-color: #2f2f37 transparent;
}
.sidebar:hover::-webkit-scrollbar-thumb {
background: #2f2f37;
background-clip: padding-box;
}
.sidebar:hover::-webkit-scrollbar-thumb:hover {
background: #41414c;
background-clip: padding-box;
}

/* ---- loops panel ---- */
.loops {
Expand Down