From 49d98aed77349b4594c9a4b9b59fe36c1b73a469 Mon Sep 17 00:00:00 2001 From: dinq Date: Fri, 25 Sep 2026 07:29:50 -0400 Subject: [PATCH 1/4] fix(app): list draft projects from every connected Environment The draft picker only listed the active Environment's projects, so a linked host's projects were invisible until its Environment became the draft target. The picker now fans project.list across every connected Environment (same prefixed cache keys the sidebar uses), groups and labels each Environment when more than one has projects, and picking a project writes the owning Environment's id into the draft URL. Non-project chats stay local: clearing the project drops the environment hint, and a linked host's picker hides every path back to null (no clear, no Choose project, no Don't-work button) because chat-folder allocation under ~/Pie is local-only. --- .../verify-pie/features/draft-new-chat.md | 4 +- CONTEXT.md | 2 +- .../features/projects/project-select.test.tsx | 84 ++++++++++++++ .../src/features/projects/project-select.tsx | 107 ++++++++++++++---- apps/app/src/routes/draft-composer.tsx | 43 +++++-- apps/app/src/routes/draft.tsx | 103 ++++++++++++++--- 6 files changed, 291 insertions(+), 52 deletions(-) create mode 100644 apps/app/src/features/projects/project-select.test.tsx diff --git a/.agents/skills/verify-pie/features/draft-new-chat.md b/.agents/skills/verify-pie/features/draft-new-chat.md index 4a66ab893..405677461 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 covers every connected Environment's imported projects; when more than one Environment has projects, each group is labeled with its Environment title (sidebar style). Picking a project writes `?projectId=` + `?environmentId=` of the owning Environment. Hovering the picker shows **X** in place of the folder icon; click **X** clears back to Choose project and drops `?environmentId=` (non-project chats are local). Opening the list shows projects, then a **Don't work in a project** button (same clear). On a linked host the picker restricts: no clear, no **Choose project** item, no **Don't work in a project**; send needs a Project. - **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, or worktree mode with no base branch. It is **not** disabled for Choose project on this device. A linked host (`?environmentId=` other than local) restricts the picker: no clear (X), no **Choose project** item, no **Don't work in a project**; send needs a Project. - 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 0ca8c248f..6cc9599aa 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 declarative Project-local Pi resources: session children approve prompts, skills, and context from that Project. Automatic Pi extension discovery is disabled for every Pie-owned child because Pie does not yet model inputs that an extension handles without starting a model turn; this intentionally makes discovered global and Project extension commands, tools, hooks, and UI unavailable. The extension request plumbing remains at the Pi protocol seam for compatibility and future explicit extension support. 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 declarative Project-local Pi resources: session children approve prompts, skills, and context from that Project. Automatic Pi extension discovery is disabled for every Pie-owned child because Pie does not yet model inputs that an extension handles without starting a model turn; this intentionally makes discovered global and Project extension commands, tools, hooks, and UI unavailable. The extension request plumbing remains at the Pi protocol seam for compatibility and future explicit extension support. 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 — a linked host does not support a non-project chat yet, so its draft picker requires an imported Project (no clear, no "Don't work in a project"). 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..c973a6e91 --- /dev/null +++ b/apps/app/src/features/projects/project-select.test.tsx @@ -0,0 +1,84 @@ +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 null mode locally: clear affordance and Don't-work button", async () => { + await render( + {}} + value={{ environmentId: "env-local", projectId: "p-one" }} + />, + ); + expect(document.querySelector('[aria-label="Clear project"]')).not.toBeNull(); + 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("requireProject (linked host) hides every path back to null", async () => { + await render( + {}} + requireProject + value={{ environmentId: "env-remote", projectId: "p-r1" }} + />, + ); + expect(document.querySelector('[aria-label="Clear project"]')).toBeNull(); + await openPicker(); + expect(document.body.textContent).toContain("p-r1"); + expect(document.body.textContent).not.toContain("Don't work in a project"); + }); + + it("labels each environment and emits the owning environment on pick", 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"); + expect(document.body.textContent).toContain("p-r1"); + 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..2536538dd 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,50 @@ 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 pickable imported projects. */ +export type ProjectGroup = { + readonly environmentId: string; + readonly environmentTitle: string; + readonly projects: ReadonlyArray; +}; + +/** What a pick emits — the project plus the Environment that owns it. */ +export type ProjectSelection = { + readonly environmentId: string; + readonly environmentTitle: string; + readonly project: Project; +}; + +/** ids are UUIDs, so ':' cannot appear in either half. */ +const selectionValue = (environmentId: string, projectId: string): string => + `${environmentId}:${projectId}`; + +// Draft project picker across every connected Environment. `null` / Choose +// project → allocate under `~/Pie` on send. `requireProject` (linked host): +// no null mode — no clear, no "Don't work in a project", because chat-folder +// allocation is local-only. export function ProjectSelect({ + group, + groups, onChange, - projects, + requireProject = false, value, }: { - onChange: (projectId: string | null) => void; - projects: ReadonlyArray; - value: string | null; + /** Label each Environment's group (more than one has projects). */ + readonly group?: boolean; + /** Environments in switcher order; empty ones already filtered out. */ + readonly groups: ReadonlyArray; + onChange: (next: ProjectSelection | null) => void; + readonly requireProject?: boolean; + /** Currently picked project, or null for Choose project. */ + 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 selectedGroup = groups.find((entry) => entry.environmentId === value?.environmentId); + const selectedProject = selectedGroup?.projects.find( + (project) => project.id === value?.projectId, + ); return (
({ + label: environment.title, + value: environment.environmentId, + }))} + onValueChange={(next) => { + if (typeof next === "string") onChange(next); + }} + value={value} + > + + + + + {environments.map((environment) => ( + + {environment.title} + + ))} + + + ); +} diff --git a/apps/app/src/features/projects/project-select.test.tsx b/apps/app/src/features/projects/project-select.test.tsx index c973a6e91..60285341e 100644 --- a/apps/app/src/features/projects/project-select.test.tsx +++ b/apps/app/src/features/projects/project-select.test.tsx @@ -1,9 +1,9 @@ import type { Project } from "@getpie/contract"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { render } from "vitest-browser-react"; import { page } from "vitest/browser"; -import { ProjectSelect, type ProjectGroup, type ProjectSelection } from "./project-select"; +import { ProjectSelect } from "./project-select"; const project = (id: string): Project => ({ id, @@ -12,17 +12,7 @@ const project = (id: string): Project => ({ 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")], -}; +const projects = [project("p-one"), project("p-two")]; async function openPicker(): Promise { await page.getByRole("combobox").click(); @@ -30,55 +20,20 @@ async function openPicker(): Promise { describe("ProjectSelect", () => { it("keeps null mode locally: clear affordance and Don't-work button", async () => { - await render( - {}} - value={{ environmentId: "env-local", projectId: "p-one" }} - />, - ); + await render( {}} projects={projects} value="p-one" />); expect(document.querySelector('[aria-label="Clear project"]')).not.toBeNull(); 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("requireProject (linked host) hides every path back to null", async () => { await render( - {}} - requireProject - value={{ environmentId: "env-remote", projectId: "p-r1" }} - />, + {}} projects={projects} requireProject value="p-one" />, ); expect(document.querySelector('[aria-label="Clear project"]')).toBeNull(); await openPicker(); - expect(document.body.textContent).toContain("p-r1"); + expect(document.body.textContent).toContain("p-two"); expect(document.body.textContent).not.toContain("Don't work in a project"); }); - - it("labels each environment and emits the owning environment on pick", 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"); - expect(document.body.textContent).toContain("p-r1"); - 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 2536538dd..49d6585a0 100644 --- a/apps/app/src/features/projects/project-select.tsx +++ b/apps/app/src/features/projects/project-select.tsx @@ -3,8 +3,6 @@ import { Button } from "@getpie/ui/components/button"; import { Select, SelectContent, - SelectGroup, - SelectGroupLabel, SelectItem, SelectSeparator, SelectTrigger, @@ -16,50 +14,23 @@ import { useState } from "react"; /** Sentinel that is not a project UUID — `allocateChatProjectDir` on send. */ const NEW_FOLDER_VALUE = "new-folder"; -/** One Environment's pickable imported projects. */ -export type ProjectGroup = { - readonly environmentId: string; - readonly environmentTitle: string; - readonly projects: ReadonlyArray; -}; - -/** What a pick emits — the project plus the Environment that owns it. */ -export type ProjectSelection = { - readonly environmentId: string; - readonly environmentTitle: string; - readonly project: Project; -}; - -/** ids are UUIDs, so ':' cannot appear in either half. */ -const selectionValue = (environmentId: string, projectId: string): string => - `${environmentId}:${projectId}`; - -// Draft project picker across every connected Environment. `null` / Choose -// project → allocate under `~/Pie` on send. `requireProject` (linked host): -// no null mode — no clear, no "Don't work in a project", because chat-folder -// allocation is local-only. +// Draft project picker for the selected Environment. `null` / Choose project → +// allocate under `~/Pie` on send. `requireProject` (linked host): no null mode, +// because chat-folder allocation is local-only. export function ProjectSelect({ - group, - groups, onChange, + projects, requireProject = false, value, }: { - /** Label each Environment's group (more than one has projects). */ - readonly group?: boolean; - /** Environments in switcher order; empty ones already filtered out. */ - readonly groups: ReadonlyArray; - onChange: (next: ProjectSelection | null) => void; - readonly requireProject?: boolean; - /** Currently picked project, or null for Choose project. */ - readonly value: { environmentId: string; projectId: string } | null; + onChange: (projectId: string | null) => void; + projects: ReadonlyArray; + requireProject?: boolean; + value: string | null; }) { const [hovered, setHovered] = useState(false); const [open, setOpen] = useState(false); - const selectedGroup = groups.find((entry) => entry.environmentId === value?.environmentId); - const selectedProject = selectedGroup?.projects.find( - (project) => project.id === value?.projectId, - ); + const selected = projects.find((project) => project.id === value); return (
- entry.projects.map((project) => ({ - label: project.name, - value: selectionValue(entry.environmentId, project.id), - })), - ), + ...projects.map((project) => ({ label: project.name, value: project.id })), ]} onOpenChange={setOpen} onValueChange={(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, - environmentTitle: entry.environmentTitle, - project, - }); - return; - } - } + if (next === NEW_FOLDER_VALUE) onChange(null); + else if (typeof next === "string") onChange(next); }} open={open} - value={ - value === null ? NEW_FOLDER_VALUE : selectionValue(value.environmentId, value.projectId) - } + value={value ?? NEW_FOLDER_VALUE} > {/* The name is only the folder's basename, so two projects can share one — the path is what actually tells them apart. */} @@ -119,7 +68,7 @@ export function ProjectSelect({ onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)} size="sm" - title={selectedProject?.path} + title={selected?.path} > {value !== null && !requireProject ? ( - {groups.map((entry) => ( - - {group ? {entry.environmentTitle} : null} - {entry.projects.map((project) => ( - - - {project.name} - {project.path} - - - ))} - + {projects.map((project) => ( + + + {project.name} + {project.path} + + ))} {value !== null && !requireProject ? ( <> - {groups.length > 0 ? : null} + {projects.length > 0 ? : null}