diff --git a/.agents/skills/verify-pie/features/draft-new-chat.md b/.agents/skills/verify-pie/features/draft-new-chat.md index 4a66ab893..9a0f9470e 100644 --- a/.agents/skills/verify-pie/features/draft-new-chat.md +++ b/.agents/skills/verify-pie/features/draft-new-chat.md @@ -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 `//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. @@ -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. diff --git a/CONTEXT.md b/CONTEXT.md index 526c5583d..cefb1b2b6 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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**: diff --git a/apps/app/src/features/projects/project-select.test.tsx b/apps/app/src/features/projects/project-select.test.tsx new file mode 100644 index 000000000..53ec76568 --- /dev/null +++ b/apps/app/src/features/projects/project-select.test.tsx @@ -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 { + await page.getByRole("combobox").click(); +} + +describe("ProjectSelect", () => { + it("keeps a single Environment unlabeled", async () => { + await render( + {}} + 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( + , + ); + 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" }), + }), + ); + }); +}); diff --git a/apps/app/src/features/projects/project-select.tsx b/apps/app/src/features/projects/project-select.tsx index c1b96341b..ac5620690 100644 --- a/apps/app/src/features/projects/project-select.tsx +++ b/apps/app/src/features/projects/project-select.tsx @@ -3,6 +3,8 @@ import { Button } from "@getpie/ui/components/button"; import { Select, SelectContent, + SelectGroup, + SelectGroupLabel, SelectItem, SelectSeparator, SelectTrigger, @@ -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; +}; + +/** 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; - value: string | null; + readonly groups: ReadonlyArray; + 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 (
({ 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. */} setHovered(true)} onMouseLeave={() => setHovered(false)} size="sm" - title={selected?.path} + title={selectedProject?.path} > {value !== null ? ( - {projects.map((project) => ( - - - {project.name} - {project.path} - - + {groups.map((entry) => ( + + {labeled ? {entry.environmentTitle} : null} + {entry.projects.map((project) => ( + + + {project.name} + {project.path} + + + ))} + ))} {value !== null ? ( <> - {projects.length > 0 ? : null} + {groups.length > 0 ? : null}