Improve onboarding defaults and issue goal fallback

This commit is contained in:
Dotta
2026-03-12 08:50:31 -05:00
parent 5f3f354b3a
commit 448e9c192b
9 changed files with 378 additions and 77 deletions

24
pnpm-lock.yaml generated
View File

@@ -14,6 +14,9 @@ importers:
'@playwright/test':
specifier: ^1.58.2
version: 1.58.2
cross-env:
specifier: ^10.1.0
version: 10.1.0
esbuild:
specifier: ^0.27.3
version: 0.27.3
@@ -68,6 +71,9 @@ importers:
drizzle-orm:
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)
embedded-postgres:
specifier: ^18.1.0-beta.16
version: 18.1.0-beta.16
picocolors:
specifier: ^1.1.1
version: 1.1.1
@@ -321,6 +327,9 @@ importers:
'@types/ws':
specifier: ^8.18.1
version: 8.18.1
cross-env:
specifier: ^10.1.0
version: 10.1.0
supertest:
specifier: ^7.0.0
version: 7.2.2
@@ -989,6 +998,9 @@ packages:
cpu: [x64]
os: [win32]
'@epic-web/invariant@1.0.0':
resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==}
'@esbuild-kit/core-utils@3.3.2':
resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==}
deprecated: 'Merged into tsx: https://tsx.is'
@@ -3424,6 +3436,11 @@ packages:
crelt@1.0.6:
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:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@@ -6741,6 +6758,8 @@ snapshots:
'@embedded-postgres/windows-x64@18.1.0-beta.16':
optional: true
'@epic-web/invariant@1.0.0': {}
'@esbuild-kit/core-utils@3.3.2':
dependencies:
esbuild: 0.18.20
@@ -9255,6 +9274,11 @@ snapshots:
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:
dependencies:
path-key: 3.1.1

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)

View File

@@ -17,9 +17,13 @@ import {
} from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import { cn } from "../lib/utils";
import { extractModelName, extractProviderIdWithFallback } from "../lib/model-utils";
import {
extractModelName,
extractProviderIdWithFallback
} from "../lib/model-utils";
import { getUIAdapter } from "../adapters";
import { defaultCreateValues } from "./agent-config-defaults";
import { parseOnboardingGoalInput } from "../lib/onboarding-goal";
import {
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX,
DEFAULT_CODEX_LOCAL_MODEL
@@ -61,11 +65,13 @@ type AdapterType =
| "http"
| "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() {
const { onboardingOpen, onboardingOptions, closeOnboarding } = useDialog();
@@ -159,12 +165,11 @@ export function OnboardingWizard() {
data: adapterModels,
error: adapterModelsError,
isLoading: adapterModelsLoading,
isFetching: adapterModelsFetching,
isFetching: adapterModelsFetching
} = useQuery({
queryKey:
createdCompanyId
? queryKeys.agents.adapterModels(createdCompanyId, adapterType)
: ["agents", "none", "adapter-models", adapterType],
queryKey: createdCompanyId
? queryKeys.agents.adapterModels(createdCompanyId, adapterType)
: ["agents", "none", "adapter-models", adapterType],
queryFn: () => agentsApi.adapterModels(createdCompanyId!, adapterType),
enabled: Boolean(createdCompanyId) && onboardingOpen && step === 2
});
@@ -181,10 +186,10 @@ export function OnboardingWizard() {
: adapterType === "gemini_local"
? "gemini"
: adapterType === "cursor"
? "agent"
: adapterType === "opencode_local"
? "opencode"
: "claude");
? "agent"
: adapterType === "opencode_local"
? "opencode"
: "claude");
useEffect(() => {
if (step !== 2) return;
@@ -219,8 +224,8 @@ export function OnboardingWizard() {
return [
{
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 }>>();
@@ -234,7 +239,7 @@ export function OnboardingWizard() {
.sort(([a], [b]) => a.localeCompare(b))
.map(([provider, entries]) => ({
provider,
entries: [...entries].sort((a, b) => a.id.localeCompare(b.id)),
entries: [...entries].sort((a, b) => a.id.localeCompare(b.id))
}));
}, [filteredModels, adapterType]);
@@ -281,7 +286,7 @@ export function OnboardingWizard() {
: adapterType === "gemini_local"
? model || DEFAULT_GEMINI_LOCAL_MODEL
: adapterType === "cursor"
? model || DEFAULT_CURSOR_LOCAL_MODEL
? model || DEFAULT_CURSOR_LOCAL_MODEL
: model,
command,
args,
@@ -347,8 +352,12 @@ export function OnboardingWizard() {
queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
if (companyGoal.trim()) {
const parsedGoal = parseOnboardingGoalInput(companyGoal);
await goalsApi.create(company.id, {
title: companyGoal.trim(),
title: parsedGoal.title,
...(parsedGoal.description
? { description: parsedGoal.description }
: {}),
level: "company",
status: "active"
});
@@ -373,19 +382,23 @@ export function OnboardingWizard() {
if (adapterType === "opencode_local") {
const selectedModelId = model.trim();
if (!selectedModelId) {
setError("OpenCode requires an explicit model in provider/model format.");
setError(
"OpenCode requires an explicit model in provider/model format."
);
return;
}
if (adapterModelsError) {
setError(
adapterModelsError instanceof Error
? adapterModelsError.message
: "Failed to load OpenCode models.",
: "Failed to load OpenCode models."
);
return;
}
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;
}
const discoveredModels = adapterModels ?? [];
@@ -393,7 +406,7 @@ export function OnboardingWizard() {
setError(
discoveredModels.length === 0
? "No OpenCode models discovered. Run `opencode models` and authenticate providers."
: `Configured OpenCode model is unavailable: ${selectedModelId}`,
: `Configured OpenCode model is unavailable: ${selectedModelId}`
);
return;
}
@@ -554,19 +567,23 @@ export function OnboardingWizard() {
</button>
{/* Left half — form */}
<div className={cn(
"w-full flex flex-col overflow-y-auto transition-[width] duration-500 ease-in-out",
step === 1 ? "md:w-1/2" : "md:w-full"
)}>
<div
className={cn(
"w-full flex flex-col overflow-y-auto transition-[width] duration-500 ease-in-out",
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">
{/* Progress tabs */}
<div className="flex items-center gap-0 mb-8 border-b border-border">
{([
{ step: 1 as Step, label: "Company", icon: Building2 },
{ step: 2 as Step, label: "Agent", icon: Bot },
{ step: 3 as Step, label: "Task", icon: ListTodo },
{ step: 4 as Step, label: "Launch", icon: Rocket },
] as const).map(({ step: s, label, icon: Icon }) => (
{(
[
{ step: 1 as Step, label: "Company", icon: Building2 },
{ step: 2 as Step, label: "Agent", icon: Bot },
{ step: 3 as Step, label: "Task", icon: ListTodo },
{ step: 4 as Step, label: "Launch", icon: Rocket }
] as const
).map(({ step: s, label, icon: Icon }) => (
<button
key={s}
type="button"
@@ -599,7 +616,14 @@ export function OnboardingWizard() {
</div>
</div>
<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
</label>
<input
@@ -611,7 +635,14 @@ export function OnboardingWizard() {
/>
</div>
<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)
</label>
<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"
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
</button>
@@ -755,8 +791,8 @@ export function OnboardingWizard() {
opt.comingSoon
? "border-border opacity-40 cursor-not-allowed"
: adapterType === opt.value
? "border-foreground bg-accent"
: "border-border hover:bg-accent/50"
? "border-foreground bg-accent"
: "border-border hover:bg-accent/50"
)}
onClick={() => {
if (opt.comingSoon) return;
@@ -783,8 +819,8 @@ export function OnboardingWizard() {
<span className="font-medium">{opt.label}</span>
<span className="text-muted-foreground text-[10px]">
{opt.comingSoon
? (opt as { disabledLabel?: string }).disabledLabel ??
"Coming soon"
? (opt as { disabledLabel?: string })
.disabledLabel ?? "Coming soon"
: opt.desc}
</span>
</button>
@@ -869,12 +905,15 @@ export function OnboardingWizard() {
setModelOpen(false);
}}
>
Default
</button>
Default
</button>
)}
<div className="max-h-[240px] overflow-y-auto">
{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" && (
<div className="px-2 py-1 text-[10px] uppercase tracking-wide text-muted-foreground">
{group.provider} ({group.entries.length})
@@ -892,8 +931,13 @@ export function OnboardingWizard() {
setModelOpen(false);
}}
>
<span className="block w-full text-left truncate" title={m.id}>
{adapterType === "opencode_local" ? extractModelName(m.id) : m.label}
<span
className="block w-full text-left truncate"
title={m.id}
>
{adapterType === "opencode_local"
? extractModelName(m.id)
: m.label}
</span>
</button>
))}
@@ -940,7 +984,8 @@ export function OnboardingWizard() {
</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">
<Check className="h-3.5 w-3.5 shrink-0" />
<span className="font-medium">Passed</span>
@@ -952,17 +997,23 @@ export function OnboardingWizard() {
{shouldSuggestUnsetAnthropicApiKey && (
<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">
Claude failed while <span className="font-mono">ANTHROPIC_API_KEY</span> is set.
You can clear it in this CEO adapter config and retry the probe.
Claude failed while{" "}
<span className="font-mono">ANTHROPIC_API_KEY</span>{" "}
is set. You can clear it in this CEO adapter config
and retry the probe.
</p>
<Button
size="sm"
variant="outline"
className="h-7 px-2.5 text-xs"
disabled={adapterEnvLoading || unsetAnthropicLoading}
disabled={
adapterEnvLoading || unsetAnthropicLoading
}
onClick={() => void handleUnsetAnthropicApiKey()}
>
{unsetAnthropicLoading ? "Retrying..." : "Unset ANTHROPIC_API_KEY"}
{unsetAnthropicLoading
? "Retrying..."
: "Unset ANTHROPIC_API_KEY"}
</Button>
</div>
)}
@@ -1001,24 +1052,43 @@ export function OnboardingWizard() {
in
env or run{" "}
<span className="font-mono">
{adapterType === "cursor"
? "agent login"
: adapterType === "codex_local"
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">
{adapterType === "cursor"
? "agent login"
: adapterType === "codex_local"
? "codex login"
: adapterType === "gemini_local"
? "gemini auth"
: "opencode auth login"}
</span>.
</p>
) : (
<p className="text-muted-foreground">
If login is required, run{" "}
<span className="font-mono">claude login</span> and
retry.
</p>
)}
</div>
)}
</span>
.
</p>
) : (
<p className="text-muted-foreground">
If login is required, run{" "}
<span className="font-mono">claude login</span>{" "}
and retry.
</p>
)}
</div>
)}
</div>
)}
@@ -1049,14 +1119,21 @@ export function OnboardingWizard() {
</div>
)}
{(adapterType === "http" || adapterType === "openclaw_gateway") && (
{(adapterType === "http" ||
adapterType === "openclaw_gateway") && (
<div>
<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>
<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"
placeholder={adapterType === "openclaw_gateway" ? "ws://127.0.0.1:18789" : "https://..."}
placeholder={
adapterType === "openclaw_gateway"
? "ws://127.0.0.1:18789"
: "https://..."
}
value={url}
onChange={(e) => setUrl(e.target.value)}
/>
@@ -1240,10 +1317,12 @@ export function OnboardingWizard() {
</div>
{/* Right half — ASCII art (hidden on mobile) */}
<div className={cn(
"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"
)}>
<div
className={cn(
"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"
)}
>
<AsciiArtAnimation />
</div>
</div>
@@ -1261,14 +1340,14 @@ function AdapterEnvironmentResult({
result.status === "pass"
? "Passed"
: result.status === "warn"
? "Warnings"
: "Failed";
? "Warnings"
: "Failed";
const statusClass =
result.status === "pass"
? "text-green-700 dark:text-green-300 border-green-300 dark:border-green-500/40 bg-green-50 dark:bg-green-500/10"
: result.status === "warn"
? "text-amber-700 dark:text-amber-300 border-amber-300 dark:border-amber-500/40 bg-amber-50 dark:bg-amber-500/10"
: "text-red-700 dark:text-red-300 border-red-300 dark:border-red-500/40 bg-red-50 dark:bg-red-500/10";
? "text-amber-700 dark:text-amber-300 border-amber-300 dark:border-amber-500/40 bg-amber-50 dark:bg-amber-500/10"
: "text-red-700 dark:text-red-300 border-red-300 dark:border-red-500/40 bg-red-50 dark:bg-red-500/10";
return (
<div className={`rounded-md border px-2.5 py-2 text-[11px] ${statusClass}`}>

View 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",
});
});
});

View 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,
};
}