diff --git a/.changeset/worktree-autodetect.md b/.changeset/worktree-autodetect.md
new file mode 100644
index 0000000..2054a09
--- /dev/null
+++ b/.changeset/worktree-autodetect.md
@@ -0,0 +1,5 @@
+---
+"@jmfederico/pi-web": patch
+---
+
+Pick up git worktrees created or removed outside PI WEB without any user action. The selected project's workspace list is re-read whenever the browser tab regains focus or becomes visible, on local and remote machines, keeping the current workspace, session, and scroll position untouched. Worktrees whose checkout directory no longer exists are hidden instead of being offered as selectable workspaces.
diff --git a/docs/faq.html b/docs/faq.html
index 1feca7c..60c14d4 100644
--- a/docs/faq.html
+++ b/docs/faq.html
@@ -101,6 +101,7 @@
How do remote machines work?
Laptop or server?
Can I use local plugins?
+ A worktree I created is missing
Sessions stop unexpectedly
Where are logs?
@@ -293,6 +294,25 @@
+ A worktree I created or deleted outside PI WEB is missing or still listed
+
+ PI WEB does not register worktrees. It lists the git worktrees of the selected project on demand, so
+ worktrees you create or delete with git worktree, a terminal, or another tool are picked up
+ without any adopt or import step. This works the same way on remote machines.
+
+
+ The workspace list is re-read when the PI WEB tab regains focus or becomes visible again, not
+ continuously. If a worktree appeared while you were already looking at PI WEB, switch to another window
+ or tab and back, and the list updates. Your selected workspace, session, and scroll position are kept.
+
+
+ Worktrees whose checkout directory no longer exists are hidden, so a directory you removed with
+ rm -rf instead of git worktree remove stops appearing as a selectable
+ workspace. Git still tracks it until you run git worktree prune.
+
+
+
Sessions stop unexpectedly
diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts
index 2581a2e..e12cc72 100644
--- a/src/client/src/components/PiWebApp.ts
+++ b/src/client/src/components/PiWebApp.ts
@@ -435,6 +435,7 @@ export class PiWebApp extends LitElement {
this.sessions.refreshSelectedSession(),
this.refreshMachineActivities(),
this.refreshWorkspaceDeletionRuns(),
+ this.workspaces.refreshSelectedProjectTopology(),
]);
}
@@ -492,6 +493,7 @@ export class PiWebApp extends LitElement {
this.loadClientConfig(),
this.refreshWorkspaceDeletionRuns(),
this.refreshCurrentWorkspaceSurface(),
+ this.workspaces.refreshSelectedProjectTopology(),
]);
this.schedulePiWebStatusRefresh();
} finally {
diff --git a/src/client/src/components/PiWebApp.workspaceTopology.test.ts b/src/client/src/components/PiWebApp.workspaceTopology.test.ts
new file mode 100644
index 0000000..8419fa1
--- /dev/null
+++ b/src/client/src/components/PiWebApp.workspaceTopology.test.ts
@@ -0,0 +1,105 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { WorkspaceController } from "../controllers/workspaceController";
+import { PiWebApp } from "./PiWebApp";
+
+type RefreshCallback = () => void | Promise;
+
+afterEach(() => {
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+});
+
+describe("PiWebApp workspace topology refresh wiring", () => {
+ it("re-lists the selected project's workspaces on the browser-resume refresh", async () => {
+ const app = createApp();
+ stubBackgroundRefreshes(app);
+ const refreshTopology = spyOnTopologyRefresh(app);
+
+ await browserResumeRefresh(app)();
+
+ expect(refreshTopology).toHaveBeenCalledOnce();
+ });
+
+ it("re-lists the selected project's workspaces on the plugin-facing app-data refresh", async () => {
+ const app = createApp();
+ stubBackgroundRefreshes(app);
+ const refreshTopology = spyOnTopologyRefresh(app);
+
+ await refreshAppData(app);
+
+ expect(refreshTopology).toHaveBeenCalledOnce();
+ });
+
+ it("still re-lists workspaces when a sibling refresh in the same resume batch fails", async () => {
+ const app = createApp();
+ stubBackgroundRefreshes(app);
+ failBackgroundRefresh(app, "refreshMachineActivities", new Error("machine activity unavailable"));
+ const refreshTopology = spyOnTopologyRefresh(app);
+
+ await expect(browserResumeRefresh(app)()).rejects.toThrow("machine activity unavailable");
+ expect(refreshTopology).toHaveBeenCalledOnce();
+ });
+});
+
+function createApp(): PiWebApp {
+ const storage = {
+ getItem: () => null,
+ setItem: () => undefined,
+ removeItem: () => undefined,
+ };
+ vi.stubGlobal("window", { location: { search: "" }, localStorage: storage });
+ return new PiWebApp();
+}
+
+/**
+ * Replaces the sibling refreshes that already have their own coverage so this test
+ * observes only whether the resume/app-data paths include workspace topology.
+ */
+function stubBackgroundRefreshes(app: PiWebApp): void {
+ const result = () => Promise.resolve();
+ for (const name of [
+ "renegotiateUnreadMachines",
+ "refreshMachineActivities",
+ "refreshWorkspaceDeletionRuns",
+ "loadClientConfig",
+ "refreshCurrentWorkspaceSurface",
+ "schedulePiWebStatusRefresh",
+ ]) {
+ if (!Reflect.set(app, name, result)) throw new Error(`Could not replace PiWebApp.${name}`);
+ }
+ const sessions: unknown = Reflect.get(app, "sessions");
+ if (typeof sessions !== "object" || sessions === null || !Reflect.set(sessions, "refreshSelectedSession", result)) {
+ throw new Error("Could not replace the selected-session refresh");
+ }
+}
+
+function failBackgroundRefresh(app: PiWebApp, name: string, error: Error): void {
+ if (!Reflect.set(app, name, () => Promise.reject(error))) throw new Error(`Could not fail PiWebApp.${name}`);
+}
+
+function spyOnTopologyRefresh(app: PiWebApp) {
+ const controller: unknown = Reflect.get(app, "workspaces");
+ if (!(controller instanceof WorkspaceController)) throw new Error("PiWebApp WorkspaceController was unavailable");
+ return vi.spyOn(controller, "refreshSelectedProjectTopology").mockResolvedValue(undefined);
+}
+
+/** The exact callback `BrowserResumeController` invokes after a focus/visibility signal. */
+function browserResumeRefresh(app: PiWebApp): RefreshCallback {
+ const resume: unknown = Reflect.get(app, "browserResume");
+ if (typeof resume !== "object" || resume === null) throw new Error("PiWebApp BrowserResumeController was unavailable");
+ const callbacks: unknown = Reflect.get(resume, "callbacks");
+ if (typeof callbacks !== "object" || callbacks === null) throw new Error("Browser resume callbacks were unavailable");
+ const refresh: unknown = Reflect.get(callbacks, "refreshAfterResume");
+ if (!isRefreshCallback(refresh)) throw new Error("The browser resume refresh callback was unavailable");
+ return refresh;
+}
+
+async function refreshAppData(app: PiWebApp): Promise {
+ const refresh: unknown = Reflect.get(app, "refreshAppData");
+ if (!isRefreshCallback(refresh)) throw new Error("PiWebApp.refreshAppData is not callable");
+ await refresh.call(app);
+}
+
+function isRefreshCallback(value: unknown): value is RefreshCallback {
+ return typeof value === "function";
+}