Improve onboarding defaults and issue goal fallback
This commit is contained in:
24
pnpm-lock.yaml
generated
24
pnpm-lock.yaml
generated
@@ -14,6 +14,9 @@ importers:
|
|||||||
'@playwright/test':
|
'@playwright/test':
|
||||||
specifier: ^1.58.2
|
specifier: ^1.58.2
|
||||||
version: 1.58.2
|
version: 1.58.2
|
||||||
|
cross-env:
|
||||||
|
specifier: ^10.1.0
|
||||||
|
version: 10.1.0
|
||||||
esbuild:
|
esbuild:
|
||||||
specifier: ^0.27.3
|
specifier: ^0.27.3
|
||||||
version: 0.27.3
|
version: 0.27.3
|
||||||
@@ -68,6 +71,9 @@ importers:
|
|||||||
drizzle-orm:
|
drizzle-orm:
|
||||||
specifier: 0.38.4
|
specifier: 0.38.4
|
||||||
version: 0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4)
|
version: 0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4)
|
||||||
|
embedded-postgres:
|
||||||
|
specifier: ^18.1.0-beta.16
|
||||||
|
version: 18.1.0-beta.16
|
||||||
picocolors:
|
picocolors:
|
||||||
specifier: ^1.1.1
|
specifier: ^1.1.1
|
||||||
version: 1.1.1
|
version: 1.1.1
|
||||||
@@ -321,6 +327,9 @@ importers:
|
|||||||
'@types/ws':
|
'@types/ws':
|
||||||
specifier: ^8.18.1
|
specifier: ^8.18.1
|
||||||
version: 8.18.1
|
version: 8.18.1
|
||||||
|
cross-env:
|
||||||
|
specifier: ^10.1.0
|
||||||
|
version: 10.1.0
|
||||||
supertest:
|
supertest:
|
||||||
specifier: ^7.0.0
|
specifier: ^7.0.0
|
||||||
version: 7.2.2
|
version: 7.2.2
|
||||||
@@ -989,6 +998,9 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
|
'@epic-web/invariant@1.0.0':
|
||||||
|
resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==}
|
||||||
|
|
||||||
'@esbuild-kit/core-utils@3.3.2':
|
'@esbuild-kit/core-utils@3.3.2':
|
||||||
resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==}
|
resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==}
|
||||||
deprecated: 'Merged into tsx: https://tsx.is'
|
deprecated: 'Merged into tsx: https://tsx.is'
|
||||||
@@ -3424,6 +3436,11 @@ packages:
|
|||||||
crelt@1.0.6:
|
crelt@1.0.6:
|
||||||
resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
|
resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
|
||||||
|
|
||||||
|
cross-env@10.1.0:
|
||||||
|
resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
cross-spawn@7.0.6:
|
cross-spawn@7.0.6:
|
||||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
@@ -6741,6 +6758,8 @@ snapshots:
|
|||||||
'@embedded-postgres/windows-x64@18.1.0-beta.16':
|
'@embedded-postgres/windows-x64@18.1.0-beta.16':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@epic-web/invariant@1.0.0': {}
|
||||||
|
|
||||||
'@esbuild-kit/core-utils@3.3.2':
|
'@esbuild-kit/core-utils@3.3.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
esbuild: 0.18.20
|
esbuild: 0.18.20
|
||||||
@@ -9255,6 +9274,11 @@ snapshots:
|
|||||||
|
|
||||||
crelt@1.0.6: {}
|
crelt@1.0.6: {}
|
||||||
|
|
||||||
|
cross-env@10.1.0:
|
||||||
|
dependencies:
|
||||||
|
'@epic-web/invariant': 1.0.0
|
||||||
|
cross-spawn: 7.0.6
|
||||||
|
|
||||||
cross-spawn@7.0.6:
|
cross-spawn@7.0.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
path-key: 3.1.1
|
path-key: 3.1.1
|
||||||
|
|||||||
59
server/src/__tests__/issue-goal-fallback.test.ts
Normal file
59
server/src/__tests__/issue-goal-fallback.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -294,13 +294,24 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
|||||||
const [ancestors, project, goal, mentionedProjectIds] = await Promise.all([
|
const [ancestors, project, goal, mentionedProjectIds] = await Promise.all([
|
||||||
svc.getAncestors(issue.id),
|
svc.getAncestors(issue.id),
|
||||||
issue.projectId ? projectsSvc.getById(issue.projectId) : null,
|
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),
|
svc.findMentionedProjectIds(issue.id),
|
||||||
]);
|
]);
|
||||||
const mentionedProjects = mentionedProjectIds.length > 0
|
const mentionedProjects = mentionedProjectIds.length > 0
|
||||||
? await projectsSvc.listByIds(issue.companyId, mentionedProjectIds)
|
? 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) => {
|
router.post("/issues/:id/read", async (req, res) => {
|
||||||
|
|||||||
@@ -1,7 +1,47 @@
|
|||||||
import { eq } from "drizzle-orm";
|
import { and, asc, eq, isNull } from "drizzle-orm";
|
||||||
import type { Db } from "@paperclipai/db";
|
import type { Db } from "@paperclipai/db";
|
||||||
import { goals } 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) {
|
export function goalService(db: Db) {
|
||||||
return {
|
return {
|
||||||
list: (companyId: string) => db.select().from(goals).where(eq(goals.companyId, companyId)),
|
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))
|
.where(eq(goals.id, id))
|
||||||
.then((rows) => rows[0] ?? null),
|
.then((rows) => rows[0] ?? null),
|
||||||
|
|
||||||
|
getDefaultCompanyGoal: (companyId: string) => getDefaultCompanyGoal(db, companyId),
|
||||||
|
|
||||||
create: (companyId: string, data: Omit<typeof goals.$inferInsert, "companyId">) =>
|
create: (companyId: string, data: Omit<typeof goals.$inferInsert, "companyId">) =>
|
||||||
db
|
db
|
||||||
.insert(goals)
|
.insert(goals)
|
||||||
|
|||||||
30
server/src/services/issue-goal-fallback.ts
Normal file
30
server/src/services/issue-goal-fallback.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -23,6 +23,8 @@ import {
|
|||||||
parseProjectExecutionWorkspacePolicy,
|
parseProjectExecutionWorkspacePolicy,
|
||||||
} from "./execution-workspace-policy.js";
|
} from "./execution-workspace-policy.js";
|
||||||
import { redactCurrentUserText } from "../log-redaction.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"];
|
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");
|
throw unprocessable("in_progress issues require an assignee");
|
||||||
}
|
}
|
||||||
return db.transaction(async (tx) => {
|
return db.transaction(async (tx) => {
|
||||||
|
const defaultCompanyGoal = await getDefaultCompanyGoal(tx, companyId);
|
||||||
let executionWorkspaceSettings =
|
let executionWorkspaceSettings =
|
||||||
(issueData.executionWorkspaceSettings as Record<string, unknown> | null | undefined) ?? null;
|
(issueData.executionWorkspaceSettings as Record<string, unknown> | null | undefined) ?? null;
|
||||||
if (executionWorkspaceSettings == null && issueData.projectId) {
|
if (executionWorkspaceSettings == null && issueData.projectId) {
|
||||||
@@ -673,6 +676,11 @@ export function issueService(db: Db) {
|
|||||||
|
|
||||||
const values = {
|
const values = {
|
||||||
...issueData,
|
...issueData,
|
||||||
|
goalId: resolveIssueGoalId({
|
||||||
|
projectId: issueData.projectId,
|
||||||
|
goalId: issueData.goalId,
|
||||||
|
defaultGoalId: defaultCompanyGoal?.id ?? null,
|
||||||
|
}),
|
||||||
...(executionWorkspaceSettings ? { executionWorkspaceSettings } : {}),
|
...(executionWorkspaceSettings ? { executionWorkspaceSettings } : {}),
|
||||||
companyId,
|
companyId,
|
||||||
issueNumber,
|
issueNumber,
|
||||||
@@ -752,6 +760,14 @@ export function issueService(db: Db) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return db.transaction(async (tx) => {
|
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
|
const updated = await tx
|
||||||
.update(issues)
|
.update(issues)
|
||||||
.set(patch)
|
.set(patch)
|
||||||
|
|||||||
@@ -17,9 +17,13 @@ import {
|
|||||||
} from "@/components/ui/popover";
|
} from "@/components/ui/popover";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { extractModelName, extractProviderIdWithFallback } from "../lib/model-utils";
|
import {
|
||||||
|
extractModelName,
|
||||||
|
extractProviderIdWithFallback
|
||||||
|
} from "../lib/model-utils";
|
||||||
import { getUIAdapter } from "../adapters";
|
import { getUIAdapter } from "../adapters";
|
||||||
import { defaultCreateValues } from "./agent-config-defaults";
|
import { defaultCreateValues } from "./agent-config-defaults";
|
||||||
|
import { parseOnboardingGoalInput } from "../lib/onboarding-goal";
|
||||||
import {
|
import {
|
||||||
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX,
|
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX,
|
||||||
DEFAULT_CODEX_LOCAL_MODEL
|
DEFAULT_CODEX_LOCAL_MODEL
|
||||||
@@ -61,11 +65,13 @@ type AdapterType =
|
|||||||
| "http"
|
| "http"
|
||||||
| "openclaw_gateway";
|
| "openclaw_gateway";
|
||||||
|
|
||||||
const DEFAULT_TASK_DESCRIPTION = `Setup yourself as the CEO. Use the ceo persona found here: [https://github.com/paperclipai/companies/blob/main/default/ceo/AGENTS.md](https://github.com/paperclipai/companies/blob/main/default/ceo/AGENTS.md)
|
const DEFAULT_TASK_DESCRIPTION = `Setup yourself as the CEO. Use the ceo persona found here:
|
||||||
|
|
||||||
Ensure you have a folder agents/ceo and then download this AGENTS.md as well as the sibling HEARTBEAT.md, SOUL.md, and TOOLS.md. and set that AGENTS.md as the path to your agents instruction file
|
https://github.com/paperclipai/companies/blob/main/default/ceo/AGENTS.md
|
||||||
|
|
||||||
And after you've finished that, hire yourself a Founding Engineer agent`;
|
Ensure you have a folder agents/ceo and then download this AGENTS.md, and sibling HEARTBEAT.md, SOULD.md, and TOOLS.md. and set that AGENTS.md as the path to your agents instruction file
|
||||||
|
|
||||||
|
After that, hire yourself a Founding Engineer agent and then plan the roadmap and tasks for your new company.`;
|
||||||
|
|
||||||
export function OnboardingWizard() {
|
export function OnboardingWizard() {
|
||||||
const { onboardingOpen, onboardingOptions, closeOnboarding } = useDialog();
|
const { onboardingOpen, onboardingOptions, closeOnboarding } = useDialog();
|
||||||
@@ -159,10 +165,9 @@ export function OnboardingWizard() {
|
|||||||
data: adapterModels,
|
data: adapterModels,
|
||||||
error: adapterModelsError,
|
error: adapterModelsError,
|
||||||
isLoading: adapterModelsLoading,
|
isLoading: adapterModelsLoading,
|
||||||
isFetching: adapterModelsFetching,
|
isFetching: adapterModelsFetching
|
||||||
} = useQuery({
|
} = useQuery({
|
||||||
queryKey:
|
queryKey: createdCompanyId
|
||||||
createdCompanyId
|
|
||||||
? queryKeys.agents.adapterModels(createdCompanyId, adapterType)
|
? queryKeys.agents.adapterModels(createdCompanyId, adapterType)
|
||||||
: ["agents", "none", "adapter-models", adapterType],
|
: ["agents", "none", "adapter-models", adapterType],
|
||||||
queryFn: () => agentsApi.adapterModels(createdCompanyId!, adapterType),
|
queryFn: () => agentsApi.adapterModels(createdCompanyId!, adapterType),
|
||||||
@@ -219,8 +224,8 @@ export function OnboardingWizard() {
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
provider: "models",
|
provider: "models",
|
||||||
entries: [...filteredModels].sort((a, b) => a.id.localeCompare(b.id)),
|
entries: [...filteredModels].sort((a, b) => a.id.localeCompare(b.id))
|
||||||
},
|
}
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
const groups = new Map<string, Array<{ id: string; label: string }>>();
|
const groups = new Map<string, Array<{ id: string; label: string }>>();
|
||||||
@@ -234,7 +239,7 @@ export function OnboardingWizard() {
|
|||||||
.sort(([a], [b]) => a.localeCompare(b))
|
.sort(([a], [b]) => a.localeCompare(b))
|
||||||
.map(([provider, entries]) => ({
|
.map(([provider, entries]) => ({
|
||||||
provider,
|
provider,
|
||||||
entries: [...entries].sort((a, b) => a.id.localeCompare(b.id)),
|
entries: [...entries].sort((a, b) => a.id.localeCompare(b.id))
|
||||||
}));
|
}));
|
||||||
}, [filteredModels, adapterType]);
|
}, [filteredModels, adapterType]);
|
||||||
|
|
||||||
@@ -347,8 +352,12 @@ export function OnboardingWizard() {
|
|||||||
queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
|
queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
|
||||||
|
|
||||||
if (companyGoal.trim()) {
|
if (companyGoal.trim()) {
|
||||||
|
const parsedGoal = parseOnboardingGoalInput(companyGoal);
|
||||||
await goalsApi.create(company.id, {
|
await goalsApi.create(company.id, {
|
||||||
title: companyGoal.trim(),
|
title: parsedGoal.title,
|
||||||
|
...(parsedGoal.description
|
||||||
|
? { description: parsedGoal.description }
|
||||||
|
: {}),
|
||||||
level: "company",
|
level: "company",
|
||||||
status: "active"
|
status: "active"
|
||||||
});
|
});
|
||||||
@@ -373,19 +382,23 @@ export function OnboardingWizard() {
|
|||||||
if (adapterType === "opencode_local") {
|
if (adapterType === "opencode_local") {
|
||||||
const selectedModelId = model.trim();
|
const selectedModelId = model.trim();
|
||||||
if (!selectedModelId) {
|
if (!selectedModelId) {
|
||||||
setError("OpenCode requires an explicit model in provider/model format.");
|
setError(
|
||||||
|
"OpenCode requires an explicit model in provider/model format."
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (adapterModelsError) {
|
if (adapterModelsError) {
|
||||||
setError(
|
setError(
|
||||||
adapterModelsError instanceof Error
|
adapterModelsError instanceof Error
|
||||||
? adapterModelsError.message
|
? adapterModelsError.message
|
||||||
: "Failed to load OpenCode models.",
|
: "Failed to load OpenCode models."
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (adapterModelsLoading || adapterModelsFetching) {
|
if (adapterModelsLoading || adapterModelsFetching) {
|
||||||
setError("OpenCode models are still loading. Please wait and try again.");
|
setError(
|
||||||
|
"OpenCode models are still loading. Please wait and try again."
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const discoveredModels = adapterModels ?? [];
|
const discoveredModels = adapterModels ?? [];
|
||||||
@@ -393,7 +406,7 @@ export function OnboardingWizard() {
|
|||||||
setError(
|
setError(
|
||||||
discoveredModels.length === 0
|
discoveredModels.length === 0
|
||||||
? "No OpenCode models discovered. Run `opencode models` and authenticate providers."
|
? "No OpenCode models discovered. Run `opencode models` and authenticate providers."
|
||||||
: `Configured OpenCode model is unavailable: ${selectedModelId}`,
|
: `Configured OpenCode model is unavailable: ${selectedModelId}`
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -554,19 +567,23 @@ export function OnboardingWizard() {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Left half — form */}
|
{/* Left half — form */}
|
||||||
<div className={cn(
|
<div
|
||||||
|
className={cn(
|
||||||
"w-full flex flex-col overflow-y-auto transition-[width] duration-500 ease-in-out",
|
"w-full flex flex-col overflow-y-auto transition-[width] duration-500 ease-in-out",
|
||||||
step === 1 ? "md:w-1/2" : "md:w-full"
|
step === 1 ? "md:w-1/2" : "md:w-full"
|
||||||
)}>
|
)}
|
||||||
|
>
|
||||||
<div className="w-full max-w-md mx-auto my-auto px-8 py-12 shrink-0">
|
<div className="w-full max-w-md mx-auto my-auto px-8 py-12 shrink-0">
|
||||||
{/* Progress tabs */}
|
{/* Progress tabs */}
|
||||||
<div className="flex items-center gap-0 mb-8 border-b border-border">
|
<div className="flex items-center gap-0 mb-8 border-b border-border">
|
||||||
{([
|
{(
|
||||||
|
[
|
||||||
{ step: 1 as Step, label: "Company", icon: Building2 },
|
{ step: 1 as Step, label: "Company", icon: Building2 },
|
||||||
{ step: 2 as Step, label: "Agent", icon: Bot },
|
{ step: 2 as Step, label: "Agent", icon: Bot },
|
||||||
{ step: 3 as Step, label: "Task", icon: ListTodo },
|
{ step: 3 as Step, label: "Task", icon: ListTodo },
|
||||||
{ step: 4 as Step, label: "Launch", icon: Rocket },
|
{ step: 4 as Step, label: "Launch", icon: Rocket }
|
||||||
] as const).map(({ step: s, label, icon: Icon }) => (
|
] as const
|
||||||
|
).map(({ step: s, label, icon: Icon }) => (
|
||||||
<button
|
<button
|
||||||
key={s}
|
key={s}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -599,7 +616,14 @@ export function OnboardingWizard() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 group">
|
<div className="mt-3 group">
|
||||||
<label className={cn("text-xs mb-1 block transition-colors", companyName.trim() ? "text-foreground" : "text-muted-foreground group-focus-within:text-foreground")}>
|
<label
|
||||||
|
className={cn(
|
||||||
|
"text-xs mb-1 block transition-colors",
|
||||||
|
companyName.trim()
|
||||||
|
? "text-foreground"
|
||||||
|
: "text-muted-foreground group-focus-within:text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
Company name
|
Company name
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -611,7 +635,14 @@ export function OnboardingWizard() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="group">
|
<div className="group">
|
||||||
<label className={cn("text-xs mb-1 block transition-colors", companyGoal.trim() ? "text-foreground" : "text-muted-foreground group-focus-within:text-foreground")}>
|
<label
|
||||||
|
className={cn(
|
||||||
|
"text-xs mb-1 block transition-colors",
|
||||||
|
companyGoal.trim()
|
||||||
|
? "text-foreground"
|
||||||
|
: "text-muted-foreground group-focus-within:text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
Mission / goal (optional)
|
Mission / goal (optional)
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -707,7 +738,12 @@ export function OnboardingWizard() {
|
|||||||
className="flex items-center gap-1.5 mt-3 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
className="flex items-center gap-1.5 mt-3 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
onClick={() => setShowMoreAdapters((v) => !v)}
|
onClick={() => setShowMoreAdapters((v) => !v)}
|
||||||
>
|
>
|
||||||
<ChevronDown className={cn("h-3 w-3 transition-transform", showMoreAdapters ? "rotate-0" : "-rotate-90")} />
|
<ChevronDown
|
||||||
|
className={cn(
|
||||||
|
"h-3 w-3 transition-transform",
|
||||||
|
showMoreAdapters ? "rotate-0" : "-rotate-90"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
More Agent Adapter Types
|
More Agent Adapter Types
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -783,8 +819,8 @@ export function OnboardingWizard() {
|
|||||||
<span className="font-medium">{opt.label}</span>
|
<span className="font-medium">{opt.label}</span>
|
||||||
<span className="text-muted-foreground text-[10px]">
|
<span className="text-muted-foreground text-[10px]">
|
||||||
{opt.comingSoon
|
{opt.comingSoon
|
||||||
? (opt as { disabledLabel?: string }).disabledLabel ??
|
? (opt as { disabledLabel?: string })
|
||||||
"Coming soon"
|
.disabledLabel ?? "Coming soon"
|
||||||
: opt.desc}
|
: opt.desc}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -874,7 +910,10 @@ export function OnboardingWizard() {
|
|||||||
)}
|
)}
|
||||||
<div className="max-h-[240px] overflow-y-auto">
|
<div className="max-h-[240px] overflow-y-auto">
|
||||||
{groupedModels.map((group) => (
|
{groupedModels.map((group) => (
|
||||||
<div key={group.provider} className="mb-1 last:mb-0">
|
<div
|
||||||
|
key={group.provider}
|
||||||
|
className="mb-1 last:mb-0"
|
||||||
|
>
|
||||||
{adapterType === "opencode_local" && (
|
{adapterType === "opencode_local" && (
|
||||||
<div className="px-2 py-1 text-[10px] uppercase tracking-wide text-muted-foreground">
|
<div className="px-2 py-1 text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||||
{group.provider} ({group.entries.length})
|
{group.provider} ({group.entries.length})
|
||||||
@@ -892,8 +931,13 @@ export function OnboardingWizard() {
|
|||||||
setModelOpen(false);
|
setModelOpen(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="block w-full text-left truncate" title={m.id}>
|
<span
|
||||||
{adapterType === "opencode_local" ? extractModelName(m.id) : m.label}
|
className="block w-full text-left truncate"
|
||||||
|
title={m.id}
|
||||||
|
>
|
||||||
|
{adapterType === "opencode_local"
|
||||||
|
? extractModelName(m.id)
|
||||||
|
: m.label}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -940,7 +984,8 @@ export function OnboardingWizard() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{adapterEnvResult && adapterEnvResult.status === "pass" ? (
|
{adapterEnvResult &&
|
||||||
|
adapterEnvResult.status === "pass" ? (
|
||||||
<div className="flex items-center gap-2 rounded-md border border-green-300 dark:border-green-500/40 bg-green-50 dark:bg-green-500/10 px-3 py-2 text-xs text-green-700 dark:text-green-300 animate-in fade-in slide-in-from-bottom-1 duration-300">
|
<div className="flex items-center gap-2 rounded-md border border-green-300 dark:border-green-500/40 bg-green-50 dark:bg-green-500/10 px-3 py-2 text-xs text-green-700 dark:text-green-300 animate-in fade-in slide-in-from-bottom-1 duration-300">
|
||||||
<Check className="h-3.5 w-3.5 shrink-0" />
|
<Check className="h-3.5 w-3.5 shrink-0" />
|
||||||
<span className="font-medium">Passed</span>
|
<span className="font-medium">Passed</span>
|
||||||
@@ -952,17 +997,23 @@ export function OnboardingWizard() {
|
|||||||
{shouldSuggestUnsetAnthropicApiKey && (
|
{shouldSuggestUnsetAnthropicApiKey && (
|
||||||
<div className="rounded-md border border-amber-300/60 bg-amber-50/40 px-2.5 py-2 space-y-2">
|
<div className="rounded-md border border-amber-300/60 bg-amber-50/40 px-2.5 py-2 space-y-2">
|
||||||
<p className="text-[11px] text-amber-900/90 leading-relaxed">
|
<p className="text-[11px] text-amber-900/90 leading-relaxed">
|
||||||
Claude failed while <span className="font-mono">ANTHROPIC_API_KEY</span> is set.
|
Claude failed while{" "}
|
||||||
You can clear it in this CEO adapter config and retry the probe.
|
<span className="font-mono">ANTHROPIC_API_KEY</span>{" "}
|
||||||
|
is set. You can clear it in this CEO adapter config
|
||||||
|
and retry the probe.
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-7 px-2.5 text-xs"
|
className="h-7 px-2.5 text-xs"
|
||||||
disabled={adapterEnvLoading || unsetAnthropicLoading}
|
disabled={
|
||||||
|
adapterEnvLoading || unsetAnthropicLoading
|
||||||
|
}
|
||||||
onClick={() => void handleUnsetAnthropicApiKey()}
|
onClick={() => void handleUnsetAnthropicApiKey()}
|
||||||
>
|
>
|
||||||
{unsetAnthropicLoading ? "Retrying..." : "Unset ANTHROPIC_API_KEY"}
|
{unsetAnthropicLoading
|
||||||
|
? "Retrying..."
|
||||||
|
: "Unset ANTHROPIC_API_KEY"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1000,6 +1051,24 @@ export function OnboardingWizard() {
|
|||||||
</span>{" "}
|
</span>{" "}
|
||||||
in
|
in
|
||||||
env or run{" "}
|
env or run{" "}
|
||||||
|
<span className="font-mono">
|
||||||
|
Respond with hello.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
{adapterType === "cursor" ||
|
||||||
|
adapterType === "codex_local" ||
|
||||||
|
adapterType === "gemini_local" ||
|
||||||
|
adapterType === "opencode_local" ? (
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
If auth fails, set{" "}
|
||||||
|
<span className="font-mono">
|
||||||
|
{adapterType === "cursor"
|
||||||
|
? "CURSOR_API_KEY"
|
||||||
|
: adapterType === "gemini_local"
|
||||||
|
? "GEMINI_API_KEY"
|
||||||
|
: "OPENAI_API_KEY"}
|
||||||
|
</span>{" "}
|
||||||
|
in env or run{" "}
|
||||||
<span className="font-mono">
|
<span className="font-mono">
|
||||||
{adapterType === "cursor"
|
{adapterType === "cursor"
|
||||||
? "agent login"
|
? "agent login"
|
||||||
@@ -1008,13 +1077,14 @@ export function OnboardingWizard() {
|
|||||||
: adapterType === "gemini_local"
|
: adapterType === "gemini_local"
|
||||||
? "gemini auth"
|
? "gemini auth"
|
||||||
: "opencode auth login"}
|
: "opencode auth login"}
|
||||||
</span>.
|
</span>
|
||||||
|
.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
If login is required, run{" "}
|
If login is required, run{" "}
|
||||||
<span className="font-mono">claude login</span> and
|
<span className="font-mono">claude login</span>{" "}
|
||||||
retry.
|
and retry.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1049,14 +1119,21 @@ export function OnboardingWizard() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(adapterType === "http" || adapterType === "openclaw_gateway") && (
|
{(adapterType === "http" ||
|
||||||
|
adapterType === "openclaw_gateway") && (
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-muted-foreground mb-1 block">
|
<label className="text-xs text-muted-foreground mb-1 block">
|
||||||
{adapterType === "openclaw_gateway" ? "Gateway URL" : "Webhook URL"}
|
{adapterType === "openclaw_gateway"
|
||||||
|
? "Gateway URL"
|
||||||
|
: "Webhook URL"}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm font-mono outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50"
|
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm font-mono outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50"
|
||||||
placeholder={adapterType === "openclaw_gateway" ? "ws://127.0.0.1:18789" : "https://..."}
|
placeholder={
|
||||||
|
adapterType === "openclaw_gateway"
|
||||||
|
? "ws://127.0.0.1:18789"
|
||||||
|
: "https://..."
|
||||||
|
}
|
||||||
value={url}
|
value={url}
|
||||||
onChange={(e) => setUrl(e.target.value)}
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
/>
|
/>
|
||||||
@@ -1240,10 +1317,12 @@ export function OnboardingWizard() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right half — ASCII art (hidden on mobile) */}
|
{/* Right half — ASCII art (hidden on mobile) */}
|
||||||
<div className={cn(
|
<div
|
||||||
|
className={cn(
|
||||||
"hidden md:block overflow-hidden bg-[#1d1d1d] transition-[width,opacity] duration-500 ease-in-out",
|
"hidden md:block overflow-hidden bg-[#1d1d1d] transition-[width,opacity] duration-500 ease-in-out",
|
||||||
step === 1 ? "w-1/2 opacity-100" : "w-0 opacity-0"
|
step === 1 ? "w-1/2 opacity-100" : "w-0 opacity-0"
|
||||||
)}>
|
)}
|
||||||
|
>
|
||||||
<AsciiArtAnimation />
|
<AsciiArtAnimation />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
22
ui/src/lib/onboarding-goal.test.ts
Normal file
22
ui/src/lib/onboarding-goal.test.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { parseOnboardingGoalInput } from "./onboarding-goal";
|
||||||
|
|
||||||
|
describe("parseOnboardingGoalInput", () => {
|
||||||
|
it("uses a single-line goal as the title only", () => {
|
||||||
|
expect(parseOnboardingGoalInput("Ship the MVP")).toEqual({
|
||||||
|
title: "Ship the MVP",
|
||||||
|
description: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("splits a multiline goal into title and description", () => {
|
||||||
|
expect(
|
||||||
|
parseOnboardingGoalInput(
|
||||||
|
"Ship the MVP\nLaunch to 10 design partners\nMeasure retention",
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
title: "Ship the MVP",
|
||||||
|
description: "Launch to 10 design partners\nMeasure retention",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
18
ui/src/lib/onboarding-goal.ts
Normal file
18
ui/src/lib/onboarding-goal.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
export function parseOnboardingGoalInput(raw: string): {
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
} {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return { title: "", description: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const [firstLine, ...restLines] = trimmed.split(/\r?\n/);
|
||||||
|
const title = firstLine.trim();
|
||||||
|
const description = restLines.join("\n").trim();
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
description: description.length > 0 ? description : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user