Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
49d98ae
fix(app): list draft projects from every connected Environment
iamdin Sep 25, 2026
8c0c534
fix(app): filter the draft project list by the selected Environment
iamdin Sep 26, 2026
4f920b7
fix(app): group draft projects by Environment in one picker
iamdin Sep 26, 2026
0da9c48
Merge branch 'main' into fix/draft-projects-per-environment
oxwen11 Sep 30, 2026
57316b6
Merge branch 'main' into fix/draft-projects-per-environment
oxwen11 Oct 1, 2026
487bdc7
fix(app): preserve draft while remote projects load
iamdin Oct 1, 2026
82b0845
Merge branch 'main' into fix/draft-projects-per-environment
oxwen11 Oct 1, 2026
fed81bb
Merge branch 'main' into fix/draft-projects-per-environment
oxwen11 Oct 5, 2026
23615a7
Merge branch 'main' into fix/draft-projects-per-environment
oxwen11 Oct 5, 2026
b942885
Merge branch 'main' into fix/draft-projects-per-environment
oxwen11 Oct 5, 2026
a274189
Merge branch 'main' into fix/draft-projects-per-environment
oxwen11 Oct 5, 2026
922701f
Merge branch 'main' into fix/draft-projects-per-environment
oxwen11 Oct 5, 2026
de48238
Merge branch 'main' into fix/draft-projects-per-environment
iamdin Oct 5, 2026
9203dec
Merge branch 'main' into fix/draft-projects-per-environment
oxwen11 Oct 6, 2026
c66e223
Merge branch 'main' into fix/draft-projects-per-environment
oxwen11 Oct 6, 2026
99b7957
Merge branch 'main' into fix/draft-projects-per-environment
oxwen11 Oct 6, 2026
4f32a2b
Merge branch 'main' into fix/draft-projects-per-environment
oxwen11 Oct 6, 2026
0c1e7e5
Merge branch 'main' into fix/draft-projects-per-environment
oxwen11 Oct 6, 2026
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
4 changes: 2 additions & 2 deletions .agents/skills/verify-pie/features/draft-new-chat.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ The new-session surface. `/` has no UI — it redirects to `/draft`. A send crea
## Sub-features

- **Centered composer** always: project picker, optional git workspace/worktree controls, model select, TipTap input, submit. Zero projects is not an empty state — the picker stays on **Choose project**. Import remains on the sidebar.
- **Project picker** — default **Choose project** (no `?projectId=`). The folder icon is part of the trigger. Choosing a project writes `?projectId=` (replace). Hovering the picker shows **X** in place of the folder icon; click **X** clears `?projectId=` back to Choose project without opening the list. Opening the list shows projects, then a **Don't work in a project** button (same clear).
- **Project picker** — default **Choose project** (no `?projectId=`). The folder icon is part of the trigger. The list is every connected Environment's imported projects. When more than one Environment has projects, they are split into groups labeled with the Environment title — there is no separate Environment picker. Choosing a project writes `?projectId=` and that Environment's `?environmentId=`. Hovering the picker shows **X**; click **X** clears both (non-project chats are local). Opening the list shows projects, then **Don't work in a project** (same clear).
- **Choose project send** — `project.allocate` creates `<root>/<YYYY-MM-DD>/Chat-1/` (then `Chat-2`, …), registers it as a Project with `type: "chat"`, then `session.create`. Sidebar **Recent** lists the session (title is the prompt). **Projects** does not show the chat leaf. The picker still lists imported folders only.
- **Workspace mode** (git repos only, after a real Project is selected): **Current directory** vs **New worktree**. Worktree requires a **base branch** (`aria-label="Base branch for worktree"`). Non-git shows **Not a Git repository**. Missing folder shows **Workspace unavailable** and blocks send.
- **Model select** — options from Pi `get_available_models`, grouped by provider, trigger shows the model name or **Default**, including when the model list is empty. Default model is written into `?provider=&modelId=` once.
Expand Down Expand Up @@ -51,7 +51,7 @@ Worktree path (only if the imported folder is a git repo): switch the workspace
## Gotchas

- CDP Enter does **not** submit. Click the arrow button. Draft submit has **no aria-label** — identify it as the composer submit after the field is non-empty.
- Send is disabled when: input empty, workspace unavailable, create in flight, or worktree mode with no base branch. It is **not** disabled for Choose project.
- Send is disabled when: input empty, workspace unavailable, create in flight, worktree mode with no base branch, or a linked host with no project selected. It is **not** disabled for Choose project on this device.
- Model select missing ≠ broken draft. Pi unavailable ⇒ empty list ⇒ component returns `null`.
- After adding/renaming routes, load `/` through Vite before typechecking (`routeTree.gen.ts` is plugin-generated).
- Verify sets `PIE_CHAT_PROJECTS_DIR=$PIE_HOME/Pie`. `HOME` and `~/.pi/agent` stay the operator's.
2 changes: 1 addition & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ Glossary of project-specific terms. pie integrates the Pi coding agent into the
## Session Domain

**Project**:
A working directory the user has registered with the server, identified by a server-generated UUID. The single source of the projectId → directory mapping; the directory field is `path`. Registration is the trust boundary for Project-local Pi resources: session children approve prompts, skills, and context from that Project. Pie-owned children load and execute Pi's built-in extensions, global extensions, and that Project's extensions. Registering a Project trusts execution of its extension code. The daemon model list (`listAvailablePiModels`) does not load extensions. Sessions always resolve their working directory through a Project, never from a caller-supplied path. A session may start without picking an existing Project: `project.allocateChatProjectDir` creates a folder under the **chat-project root**, registers it with `type: "chat"`, then `session.create` uses that id as usual. Chat projects stay off the **Projects** sidebar and picker; their sessions appear under **Recent**.
A working directory the user has registered with the server, identified by a server-generated UUID. The single source of the projectId → directory mapping; the directory field is `path`. Registration is the trust boundary for Project-local Pi resources: session children approve prompts, skills, and context from that Project. Pie-owned children load and execute Pi's built-in extensions, global extensions, and that Project's extensions. Registering a Project trusts execution of its extension code. The daemon model list (`listAvailablePiModels`) does not load extensions. Sessions always resolve their working directory through a Project, never from a caller-supplied path. A session may start without picking an existing Project: `project.allocateChatProjectDir` creates a folder under the **chat-project root**, registers it with `type: "chat"`, then `session.create` uses that id as usual. That path is local-only. The draft project picker lists every connected Environment's imported projects, split into groups labeled by Environment when more than one has projects. Picking a project addresses that Environment. A linked host still cannot allocate a non-project chat. Chat projects stay off the **Projects** sidebar and picker; their sessions appear under **Recent**.
_Avoid_: workspace, repo, cwd (for the Project field)

**Chat-project root**:
Expand Down
66 changes: 66 additions & 0 deletions apps/app/src/features/projects/project-select.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import type { Project } from "@getpie/contract";
import { describe, expect, it, vi } from "vitest";
import { render } from "vitest-browser-react";
import { page } from "vitest/browser";

import { ProjectSelect, type ProjectGroup, type ProjectSelection } from "./project-select";

const project = (id: string): Project => ({
id,
name: id,
path: `/tmp/${id}`,
createdAt: "2026-01-01T00:00:00.000Z",
});

const localGroup: ProjectGroup = {
environmentId: "env-local",
environmentTitle: "This device",
projects: [project("p-one"), project("p-two")],
};

const remoteGroup: ProjectGroup = {
environmentId: "env-remote",
environmentTitle: "macbook-pro-m1",
projects: [project("p-r1")],
};

async function openPicker(): Promise<void> {
await page.getByRole("combobox").click();
}

describe("ProjectSelect", () => {
it("keeps a single Environment unlabeled", async () => {
await render(
<ProjectSelect
groups={[localGroup]}
onChange={() => {}}
value={{ environmentId: "env-local", projectId: "p-one" }}
/>,
);
await openPicker();
expect(document.body.textContent).toContain("p-two");
expect(document.body.textContent).toContain("Don't work in a project");
expect(document.body.textContent).not.toContain("This device");
});

it("splits projects by Environment and emits the owning id", async () => {
const onChange = vi.fn<(next: ProjectSelection | null) => void>();
await render(
<ProjectSelect
groups={[localGroup, remoteGroup]}
onChange={onChange}
value={{ environmentId: "env-local", projectId: "p-one" }}
/>,
);
await openPicker();
expect(document.body.textContent).toContain("This device");
expect(document.body.textContent).toContain("macbook-pro-m1");
await page.getByText("p-r1").click();
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({
environmentId: "env-remote",
project: expect.objectContaining({ id: "p-r1" }),
}),
);
});
});
91 changes: 68 additions & 23 deletions apps/app/src/features/projects/project-select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { Button } from "@getpie/ui/components/button";
import {
Select,
SelectContent,
SelectGroup,
SelectGroupLabel,
SelectItem,
SelectSeparator,
SelectTrigger,
Expand All @@ -14,19 +16,40 @@ import { useState } from "react";
/** Sentinel that is not a project UUID — `allocateChatProjectDir` on send. */
const NEW_FOLDER_VALUE = "new-folder";

// Draft project picker. `null` / Choose project → allocate under `~/Pie` on send.
/** One Environment's imported projects, in switcher order. */
export type ProjectGroup = {
readonly environmentId: string;
readonly environmentTitle: string;
readonly projects: ReadonlyArray<Project>;
};

/** A pick names the project and the Environment that owns it. */
export type ProjectSelection = {
readonly environmentId: string;
readonly project: Project;
};

const selectionValue = (environmentId: string, projectId: string): string =>
`${environmentId}:${projectId}`;

// One picker. Multiple Environments are groups inside it — not a prior
// environment select. `null` / Choose project allocates under `~/Pie` locally.
export function ProjectSelect({
groups,
onChange,
projects,
value,
}: {
onChange: (projectId: string | null) => void;
projects: ReadonlyArray<Project>;
value: string | null;
readonly groups: ReadonlyArray<ProjectGroup>;
onChange: (next: ProjectSelection | null) => void;
readonly value: { environmentId: string; projectId: string } | null;
}) {
const [hovered, setHovered] = useState(false);
const [open, setOpen] = useState(false);
const selected = projects.find((project) => project.id === value);
const labeled = groups.length > 1;
const selectedGroup = groups.find((entry) => entry.environmentId === value?.environmentId);
const selectedProject = selectedGroup?.projects.find(
(project) => project.id === value?.projectId,
);

return (
<div
Expand All @@ -45,26 +68,40 @@ export function ProjectSelect({
<Select
items={[
{ label: "Choose project", value: NEW_FOLDER_VALUE },
...projects.map((project) => ({ label: project.name, value: project.id })),
...groups.flatMap((entry) =>
entry.projects.map((project) => ({
label: project.name,
value: selectionValue(entry.environmentId, project.id),
})),
),
]}
onOpenChange={setOpen}
onValueChange={(next) => {
if (next === NEW_FOLDER_VALUE) onChange(null);
else if (typeof next === "string") onChange(next);
if (next === NEW_FOLDER_VALUE) {
onChange(null);
return;
}
for (const entry of groups) {
const project = entry.projects.find(
(candidate) => selectionValue(entry.environmentId, candidate.id) === next,
);
if (project !== undefined) {
onChange({ environmentId: entry.environmentId, project });
return;
}
}
}}
open={open}
value={value ?? NEW_FOLDER_VALUE}
value={
value === null ? NEW_FOLDER_VALUE : selectionValue(value.environmentId, value.projectId)
}
>
{/* The name is only the folder's basename, so two projects can share one —
the path is what actually tells them apart. */}
{/* The draft header row owns the edge bleed (-mx-4) for every pick; a
trigger-level margin would stack with it and poke past the card. */}
<SelectTrigger
className="hover:bg-accent w-auto max-w-56 min-w-0 justify-self-start border-transparent bg-transparent shadow-none before:hidden dark:bg-transparent [&_[data-slot=select-icon]]:hidden"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
size="sm"
title={selected?.path}
title={selectedProject?.path}
>
{value !== null ? (
<span
Expand All @@ -84,17 +121,25 @@ export function ProjectSelect({
<SelectValue placeholder="Choose project" />
</SelectTrigger>
<SelectContent>
{projects.map((project) => (
<SelectItem key={project.id} value={project.id}>
<span className="flex min-w-0 flex-col">
<span className="truncate">{project.name}</span>
<span className="text-muted-foreground truncate text-xs">{project.path}</span>
</span>
</SelectItem>
{groups.map((entry) => (
<SelectGroup key={entry.environmentId}>
{labeled ? <SelectGroupLabel>{entry.environmentTitle}</SelectGroupLabel> : null}
{entry.projects.map((project) => (
<SelectItem
key={selectionValue(entry.environmentId, project.id)}
value={selectionValue(entry.environmentId, project.id)}
>
<span className="flex min-w-0 flex-col">
<span className="truncate">{project.name}</span>
<span className="text-muted-foreground truncate text-xs">{project.path}</span>
</span>
</SelectItem>
))}
</SelectGroup>
))}
{value !== null ? (
<>
{projects.length > 0 ? <SelectSeparator /> : null}
{groups.length > 0 ? <SelectSeparator /> : null}
<Button
className="w-full justify-start"
onClick={() => {
Expand Down
149 changes: 149 additions & 0 deletions apps/app/src/routes/-draft-loading.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import type { Project } from "@getpie/contract";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
createMemoryHistory,
createRootRouteWithContext,
createRoute,
createRouter,
Outlet,
RouterProvider,
} from "@tanstack/react-router";
import { expect, it } from "vitest";
import { render } from "vitest-browser-react";
import { page } from "vitest/browser";

import { ChatManagerContext } from "@/features/chat/runtime/chat-context";
import { createEnvironmentRpc } from "@/lib/environment-rpc";
import type { EnvironmentSnapshot } from "@/platform";
import { PlatformProvider } from "@/platform-provider";
import { Route as DraftRoute } from "@/routes/draft";

import "@/index.css";

it.each(["resolve", "reject"])(
"preserves the real draft editor while a newly connected Environment loads and %ss",
async (outcome) => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const localProject: Project = {
id: "local-project",
name: "Local project",
path: "/tmp/draft-loading-project",
createdAt: "2026-01-01T00:00:00.000Z",
};
let resolveRemote = (_projects: readonly Project[]): void => {};
let rejectRemote = (_error: Error): void => {};
const remoteProjects = new Promise<readonly Project[]>((resolve, reject) => {
resolveRemote = resolve;
rejectRemote = reject;
});
let snapshot: EnvironmentSnapshot = { revision: 0, connecting: [], remotes: [] };
const listeners = new Set<(next: EnvironmentSnapshot) => void>();
const connection = {
httpBaseUrl: "http://127.0.0.1:1",
wsBaseUrl: "ws://127.0.0.1:1",
token: "test-only",
};
const environmentRpc = createEnvironmentRpc({
localId: "local",
queryClient,
resolveRemote: () => connection,
createRemoteLink: () => ({ call: async () => remoteProjects }),
localLink: {
call: async (path) => {
switch (path.join(".")) {
case "project.list":
return [localProject];
case "agent.listModels":
return { models: [] };
case "agent.commands":
return [];
case "git.branch":
return { kind: "not-repository" };
default:
throw new Error(`Unexpected RPC: ${path.join(".")}`);
}
},
},
});
const context = { localEnvironmentId: "local", environmentRpc };
const root = createRootRouteWithContext<typeof context>()({
component: () => (
<PlatformProvider
value={{
ssh: {
client: { available: true },
environments: {
getSnapshot: () => snapshot,
subscribe: (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
},
discoverHosts: async () => [],
connect: async () => {},
remove: async () => {},
},
}}
>
<ChatManagerContext
value={{
chatFor: () => {
throw new Error("This test must not create a Session");
},
}}
>
<Outlet />
</ChatManagerContext>
</PlatformProvider>
),
});
const draft = createRoute({
getParentRoute: () => root,
path: "draft",
component: DraftRoute.options.component,
validateSearch: DraftRoute.options.validateSearch,
});
const router = createRouter({
routeTree: root.addChildren([draft]),
context,
history: createMemoryHistory({ initialEntries: ["/draft?projectId=local-project"] }),
});
await router.load();
const view = await render(
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>,
);
try {
const editor = page.getByRole("textbox", { name: "Message" });
await editor.fill("Keep this unsent draft");
const originalEditor = editor.element();
const originalText = originalEditor.textContent ?? "";
expect(originalText).toContain("Keep this unsent draft");
snapshot = {
revision: 1,
connecting: [],
remotes: [
{ id: "remote", environmentId: "remote", alias: "remote", label: "Remote", connection },
],
};
for (const listener of listeners) listener(snapshot);
const remoteKey = environmentRpc.for("remote").project.list.queryOptions().queryKey;
await expect.poll(() => queryClient.getQueryState(remoteKey)?.fetchStatus).toBe("fetching");
await expect.element(editor).toHaveTextContent(originalText);
expect(editor.element()).toBe(originalEditor);
if (outcome === "resolve") resolveRemote([]);
else rejectRemote(new Error("Remote unavailable"));
await expect
.poll(() => queryClient.getQueryState(remoteKey)?.status)
.toBe(outcome === "resolve" ? "success" : "error");
await expect.element(editor).toHaveTextContent(originalText);
expect(editor.element()).toBe(originalEditor);
expect(document.querySelector('button[type="submit"]')).not.toBeDisabled();
} finally {
resolveRemote([]);
await view.unmount();
queryClient.clear();
}
},
);
Loading
Loading