Create project collaboration surface
This commit is contained in:
1 parent
3f34d1cf65
commit
d5a7665893
5 files changed
+108
-1
No files matched your search
@@ -9,6 +9,7 @@ import { PROVIDER_CATALOG } from "../../shared/provider-catalog";
|
||||
import { createId } from "../../shared/ids";
|
||||
import { AuditService } from "./audit-service";
|
||||
import { OrganizationService } from "./organization-service";
|
||||
import { ChatService } from "./chat-service";
|
||||
|
||||
interface OnboardingServiceDeps {
|
||||
db: DatabaseSync;
|
||||
@@ -18,10 +19,12 @@ interface OnboardingServiceDeps {
|
||||
export class OnboardingService {
|
||||
private readonly audit: AuditService;
|
||||
private readonly organization: OrganizationService;
|
||||
private readonly chat: ChatService;
|
||||
|
||||
constructor(private readonly deps: OnboardingServiceDeps) {
|
||||
this.audit = new AuditService(deps.db);
|
||||
this.organization = new OrganizationService(deps.db);
|
||||
this.chat = new ChatService(deps.db);
|
||||
}
|
||||
|
||||
bootstrap(input: BootstrapOnboardingInput): BootstrapOnboardingSummary {
|
||||
@@ -65,6 +68,7 @@ export class OnboardingService {
|
||||
});
|
||||
const goalId = this.createGoal(orgId, ceoAgentId, input, now);
|
||||
const projectId = this.createProject(orgId, goalId, ceoAgentId, input, now);
|
||||
this.createProjectChatSpace(orgId, projectName(input));
|
||||
if (workspaceId) this.bindWorkspaceToProject(workspaceId, projectId, now);
|
||||
const taskId = this.createKickoffTask(orgId, goalId, projectId, workspaceId, ceoAgentId, input, now);
|
||||
const runId = this.createTaskRun(orgId, taskId, ceoAgentId, providerId, workspaceId, now);
|
||||
@@ -143,7 +147,7 @@ export class OnboardingService {
|
||||
projectId,
|
||||
orgId,
|
||||
goalId,
|
||||
`Execution plan: ${input.goalTitle.trim()}`,
|
||||
projectName(input),
|
||||
input.companyDescription.trim(),
|
||||
ceoAgentId,
|
||||
"in_progress",
|
||||
@@ -153,6 +157,16 @@ export class OnboardingService {
|
||||
return projectId;
|
||||
}
|
||||
|
||||
private createProjectChatSpace(orgId: string, name: string): string {
|
||||
return this.chat.createSpace({
|
||||
orgId,
|
||||
spaceType: "project",
|
||||
name: projectSpaceName(name),
|
||||
topic: name,
|
||||
visibility: "workspace",
|
||||
});
|
||||
}
|
||||
|
||||
private bindWorkspaceToProject(workspaceId: string, projectId: string, now: string): void {
|
||||
this.deps.db.prepare("update workspaces set project_id = ?, updated_at = ? where id = ?")
|
||||
.run(projectId, now, workspaceId);
|
||||
@@ -250,3 +264,12 @@ export class OnboardingService {
|
||||
].filter((line) => line.length > 0).join("\n\n");
|
||||
}
|
||||
}
|
||||
|
||||
function projectName(input: BootstrapOnboardingInput): string {
|
||||
return `Execution plan: ${input.goalTitle.trim()}`;
|
||||
}
|
||||
|
||||
function projectSpaceName(name: string): string {
|
||||
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48).replace(/-+$/g, "");
|
||||
return slug || "project";
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from "../../../shared/domain";
|
||||
import { NewTaskDialog } from "./NewTaskDialog";
|
||||
import { TaskDetailDialog } from "./work/TaskDetailDialog";
|
||||
import { ProjectOverview } from "./work/ProjectOverview";
|
||||
import { canRun, latestTaskRun, nextStatus, prettyRunState, prettyStatus, providerFor, runTone } from "./work/task-helpers";
|
||||
import type { PageProps } from "./types";
|
||||
import { activeOrgId } from "./types";
|
||||
@@ -64,6 +65,7 @@ export function WorkPage({ snapshot, actions }: PageProps) {
|
||||
const workspaces = useMemo(() => snapshot.workspaces.filter((workspace) => workspace.orgId === orgId), [snapshot.workspaces, orgId]);
|
||||
const providers = useMemo(() => snapshot.providers.filter((provider) => provider.orgId === orgId), [snapshot.providers, orgId]);
|
||||
const taskRuns = useMemo(() => snapshot.taskRuns.filter((run) => run.orgId === orgId), [snapshot.taskRuns, orgId]);
|
||||
const chatSpaces = useMemo(() => snapshot.chatSpaces.filter((space) => space.orgId === orgId), [snapshot.chatSpaces, orgId]);
|
||||
|
||||
const advance = (task: TaskRecord) => {
|
||||
const next = nextStatus(task.status);
|
||||
@@ -93,6 +95,8 @@ export function WorkPage({ snapshot, actions }: PageProps) {
|
||||
{tasks.length} {tasks.length === 1 ? "task" : "tasks"} · {assigneeCount} {assigneeCount === 1 ? "assignee" : "assignees"}
|
||||
</p>
|
||||
|
||||
<ProjectOverview projects={projects} tasks={allTasks} agents={agents} workspaces={workspaces} chatSpaces={chatSpaces} />
|
||||
|
||||
{view === "board" && (
|
||||
<BoardView
|
||||
tasks={tasks}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Avatar, Pill } from "../../shared/ui";
|
||||
import { TASK_STATUS, type AgentRecord, type ChatSpaceRecord, type ProjectRecord, type TaskRecord, type WorkspaceRecord } from "../../../../shared/domain";
|
||||
|
||||
export function ProjectOverview({
|
||||
projects,
|
||||
tasks,
|
||||
agents,
|
||||
workspaces,
|
||||
chatSpaces,
|
||||
}: {
|
||||
projects: ProjectRecord[];
|
||||
tasks: TaskRecord[];
|
||||
agents: AgentRecord[];
|
||||
workspaces: WorkspaceRecord[];
|
||||
chatSpaces: ChatSpaceRecord[];
|
||||
}) {
|
||||
if (projects.length === 0) return null;
|
||||
return (
|
||||
<section style={{ marginBottom: 18 }}>
|
||||
<div className="section-label" style={{ marginBottom: 10 }}>Projects</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(260px, 1fr))", gap: 10 }}>
|
||||
{projects.map((project) => (
|
||||
<ProjectCard
|
||||
key={project.id}
|
||||
project={project}
|
||||
tasks={tasks.filter((task) => task.projectId === project.id)}
|
||||
lead={agents.find((agent) => agent.id === project.leadAgentId) ?? null}
|
||||
workspace={workspaces.find((item) => item.projectId === project.id) ?? null}
|
||||
room={chatSpaces.find((space) => space.spaceType === "project" && space.topic === project.name) ?? null}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectCard({
|
||||
project,
|
||||
tasks,
|
||||
lead,
|
||||
workspace,
|
||||
room,
|
||||
}: {
|
||||
project: ProjectRecord;
|
||||
tasks: TaskRecord[];
|
||||
lead: AgentRecord | null;
|
||||
workspace: WorkspaceRecord | null;
|
||||
room: ChatSpaceRecord | null;
|
||||
}) {
|
||||
const done = tasks.filter((task) => task.status === TASK_STATUS.DONE).length;
|
||||
return (
|
||||
<article className="card" style={{ padding: 14, minWidth: 0 }}>
|
||||
<div className="row" style={{ alignItems: "flex-start", gap: 10 }}>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={{ fontWeight: 560, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{project.name}</div>
|
||||
<div className="row" style={{ marginTop: 8, gap: 6, flexWrap: "wrap" }}>
|
||||
<Pill>{project.status.replace(/_/g, " ")}</Pill>
|
||||
<Pill>{done} / {tasks.length} tasks</Pill>
|
||||
{workspace && <Pill>{workspace.name}</Pill>}
|
||||
{room && <Pill>#{room.name}</Pill>}
|
||||
</div>
|
||||
</div>
|
||||
{lead && (
|
||||
<div className="row" style={{ gap: 6, flexShrink: 0 }}>
|
||||
<Avatar name={lead.name} size="sm" bot />
|
||||
<span style={{ fontSize: 12, color: "var(--text-2)" }}>{lead.name}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -34,6 +34,10 @@ test("onboards, hires, and creates work in the Electron shell", async () => {
|
||||
await page.getByRole("button", { name: "Launch" }).click();
|
||||
|
||||
await expect(page.getByRole("button", { name: /Acme Operations/ })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Work", exact: true }).click();
|
||||
await expect(page.getByText("Projects", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Execution plan: Ship a verified control plane").first()).toBeVisible();
|
||||
await expect(page.getByText("#execution-plan-ship-a-verified-control-plane").first()).toBeVisible();
|
||||
await page.getByRole("button", { name: "Team" }).click();
|
||||
await page.getByRole("button", { name: "Hire" }).click();
|
||||
await page.getByLabel("Name").fill("Alex Chen");
|
||||
|
||||
@@ -617,6 +617,10 @@ describe("domain services", () => {
|
||||
expect(state.workspaces).toHaveLength(1);
|
||||
expect(state.goals[0]?.status).toBe("active");
|
||||
expect(state.projects[0]?.leadAgentId).toBe(state.agents[0]?.id);
|
||||
const projectRoom = state.chatSpaces.find((space) => space.spaceType === "project" && space.topic === state.projects[0]?.name);
|
||||
expect(projectRoom).toMatchObject({ name: "execution-plan-ship-a-verified-control-plane", visibility: "workspace" });
|
||||
expect(state.chatMemberships).toContainEqual(expect.objectContaining({ spaceId: projectRoom?.id, principalType: "employee" }));
|
||||
expect(state.chatMemberships).toContainEqual(expect.objectContaining({ spaceId: projectRoom?.id, principalType: "agent", principalId: state.agents[0]?.id }));
|
||||
expect(state.tasks[0]?.assigneeAgentId).toBe(state.agents[0]?.id);
|
||||
expect(state.taskRuns[0]?.trigger).toBe("bootstrap");
|
||||
expect(state.taskRuns[0]?.executionContextId).toMatch(/^ctx_/);
|
||||
|
||||
Reference in new issue
Block a user