Merge remote-tracking branch 'public-gh/master'

* public-gh/master:
  Drop pnpm lockfile from PR
  Update ui/src/components/OnboardingWizard.tsx
  Update ui/src/components/OnboardingWizard.tsx
  Fix onboarding manual debug JSX
  Improve onboarding defaults and issue goal fallback
  Simplify adapter environment check: animate pass, show debug only on fail
  Show Claude Code and Codex as recommended, collapse other adapter types
  Animate onboarding layout when switching between Company and Agent steps
  Make onboarding wizard steps clickable tabs for easier dev navigation
  Add agent chat architecture plan
  Style tweaks for onboarding wizard step 1
  Add direct onboarding routes
This commit is contained in:
Dotta
2026-03-12 09:24:57 -05:00
10 changed files with 870 additions and 170 deletions

View File

@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import {
resolveIssueGoalId,
resolveNextIssueGoalId,
} from "../services/issue-goal-fallback.ts";
describe("issue goal fallback", () => {
it("assigns the company goal when creating an issue without project or goal", () => {
expect(
resolveIssueGoalId({
projectId: null,
goalId: null,
defaultGoalId: "goal-1",
}),
).toBe("goal-1");
});
it("keeps an explicit goal when creating an issue", () => {
expect(
resolveIssueGoalId({
projectId: null,
goalId: "goal-2",
defaultGoalId: "goal-1",
}),
).toBe("goal-2");
});
it("does not force a company goal when the issue belongs to a project", () => {
expect(
resolveIssueGoalId({
projectId: "project-1",
goalId: null,
defaultGoalId: "goal-1",
}),
).toBeNull();
});
it("backfills the company goal on update for legacy no-project issues", () => {
expect(
resolveNextIssueGoalId({
currentProjectId: null,
currentGoalId: null,
defaultGoalId: "goal-1",
}),
).toBe("goal-1");
});
it("clears the fallback when a project is added later", () => {
expect(
resolveNextIssueGoalId({
currentProjectId: null,
currentGoalId: "goal-1",
projectId: "project-1",
goalId: null,
defaultGoalId: "goal-1",
}),
).toBeNull();
});
});

View File

@@ -294,13 +294,24 @@ export function issueRoutes(db: Db, storage: StorageService) {
const [ancestors, project, goal, mentionedProjectIds] = await Promise.all([
svc.getAncestors(issue.id),
issue.projectId ? projectsSvc.getById(issue.projectId) : null,
issue.goalId ? goalsSvc.getById(issue.goalId) : null,
issue.goalId
? goalsSvc.getById(issue.goalId)
: !issue.projectId
? goalsSvc.getDefaultCompanyGoal(issue.companyId)
: null,
svc.findMentionedProjectIds(issue.id),
]);
const mentionedProjects = mentionedProjectIds.length > 0
? await projectsSvc.listByIds(issue.companyId, mentionedProjectIds)
: [];
res.json({ ...issue, ancestors, project: project ?? null, goal: goal ?? null, mentionedProjects });
res.json({
...issue,
goalId: goal?.id ?? issue.goalId,
ancestors,
project: project ?? null,
goal: goal ?? null,
mentionedProjects,
});
});
router.post("/issues/:id/read", async (req, res) => {

View File

@@ -1,7 +1,47 @@
import { eq } from "drizzle-orm";
import { and, asc, eq, isNull } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { goals } from "@paperclipai/db";
type GoalReader = Pick<Db, "select">;
export async function getDefaultCompanyGoal(db: GoalReader, companyId: string) {
const activeRootGoal = await db
.select()
.from(goals)
.where(
and(
eq(goals.companyId, companyId),
eq(goals.level, "company"),
eq(goals.status, "active"),
isNull(goals.parentId),
),
)
.orderBy(asc(goals.createdAt))
.then((rows) => rows[0] ?? null);
if (activeRootGoal) return activeRootGoal;
const anyRootGoal = await db
.select()
.from(goals)
.where(
and(
eq(goals.companyId, companyId),
eq(goals.level, "company"),
isNull(goals.parentId),
),
)
.orderBy(asc(goals.createdAt))
.then((rows) => rows[0] ?? null);
if (anyRootGoal) return anyRootGoal;
return db
.select()
.from(goals)
.where(and(eq(goals.companyId, companyId), eq(goals.level, "company")))
.orderBy(asc(goals.createdAt))
.then((rows) => rows[0] ?? null);
}
export function goalService(db: Db) {
return {
list: (companyId: string) => db.select().from(goals).where(eq(goals.companyId, companyId)),
@@ -13,6 +53,8 @@ export function goalService(db: Db) {
.where(eq(goals.id, id))
.then((rows) => rows[0] ?? null),
getDefaultCompanyGoal: (companyId: string) => getDefaultCompanyGoal(db, companyId),
create: (companyId: string, data: Omit<typeof goals.$inferInsert, "companyId">) =>
db
.insert(goals)

View File

@@ -0,0 +1,30 @@
type MaybeId = string | null | undefined;
export function resolveIssueGoalId(input: {
projectId: MaybeId;
goalId: MaybeId;
defaultGoalId: MaybeId;
}): string | null {
if (!input.projectId && !input.goalId) {
return input.defaultGoalId ?? null;
}
return input.goalId ?? null;
}
export function resolveNextIssueGoalId(input: {
currentProjectId: MaybeId;
currentGoalId: MaybeId;
projectId?: MaybeId;
goalId?: MaybeId;
defaultGoalId: MaybeId;
}): string | null {
const projectId =
input.projectId !== undefined ? input.projectId : input.currentProjectId;
const goalId =
input.goalId !== undefined ? input.goalId : input.currentGoalId;
if (!projectId && !goalId) {
return input.defaultGoalId ?? null;
}
return goalId ?? null;
}

View File

@@ -23,6 +23,8 @@ import {
parseProjectExecutionWorkspacePolicy,
} from "./execution-workspace-policy.js";
import { redactCurrentUserText } from "../log-redaction.js";
import { resolveIssueGoalId, resolveNextIssueGoalId } from "./issue-goal-fallback.js";
import { getDefaultCompanyGoal } from "./goals.js";
const ALL_ISSUE_STATUSES = ["backlog", "todo", "in_progress", "in_review", "blocked", "done", "cancelled"];
@@ -649,6 +651,7 @@ export function issueService(db: Db) {
throw unprocessable("in_progress issues require an assignee");
}
return db.transaction(async (tx) => {
const defaultCompanyGoal = await getDefaultCompanyGoal(tx, companyId);
let executionWorkspaceSettings =
(issueData.executionWorkspaceSettings as Record<string, unknown> | null | undefined) ?? null;
if (executionWorkspaceSettings == null && issueData.projectId) {
@@ -673,6 +676,11 @@ export function issueService(db: Db) {
const values = {
...issueData,
goalId: resolveIssueGoalId({
projectId: issueData.projectId,
goalId: issueData.goalId,
defaultGoalId: defaultCompanyGoal?.id ?? null,
}),
...(executionWorkspaceSettings ? { executionWorkspaceSettings } : {}),
companyId,
issueNumber,
@@ -752,6 +760,14 @@ export function issueService(db: Db) {
}
return db.transaction(async (tx) => {
const defaultCompanyGoal = await getDefaultCompanyGoal(tx, existing.companyId);
patch.goalId = resolveNextIssueGoalId({
currentProjectId: existing.projectId,
currentGoalId: existing.goalId,
projectId: issueData.projectId,
goalId: issueData.goalId,
defaultGoalId: defaultCompanyGoal?.id ?? null,
});
const updated = await tx
.update(issues)
.set(patch)