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:
329
doc/plans/agent-chat-ui-and-issue-backed-conversations.md
Normal file
329
doc/plans/agent-chat-ui-and-issue-backed-conversations.md
Normal file
@@ -0,0 +1,329 @@
|
||||
# Agent Chat UI and Issue-Backed Conversations
|
||||
|
||||
## Context
|
||||
|
||||
`PAP-475` asks two related questions:
|
||||
|
||||
1. What UI kit should Paperclip use if we add a chat surface with an agent?
|
||||
2. How should chat fit the product without breaking the current issue-centric model?
|
||||
|
||||
This is not only a component-library decision. In Paperclip today:
|
||||
|
||||
- V1 explicitly says communication is `tasks + comments only`, with no separate chat system.
|
||||
- Issues already carry assignment, audit trail, billing code, project linkage, goal linkage, and active run linkage.
|
||||
- Live run streaming already exists on issue detail pages.
|
||||
- Agent sessions already persist by `taskKey`, and today `taskKey` falls back to `issueId`.
|
||||
- The OpenClaw gateway adapter already supports an issue-scoped session key strategy.
|
||||
|
||||
That means the cheapest useful path is not "add a second messaging product inside Paperclip." It is "add a better conversational UI on top of issue and run primitives we already have."
|
||||
|
||||
## Current Constraints From the Codebase
|
||||
|
||||
### Durable work object
|
||||
|
||||
The durable object in Paperclip is the issue, not a chat thread.
|
||||
|
||||
- `IssueDetail` already combines comments, linked runs, live runs, and activity into one timeline.
|
||||
- `CommentThread` already renders markdown comments and supports reply/reassignment flows.
|
||||
- `LiveRunWidget` already renders streaming assistant/tool/system output for active runs.
|
||||
|
||||
### Session behavior
|
||||
|
||||
Session continuity is already task-shaped.
|
||||
|
||||
- `heartbeat.ts` derives `taskKey` from `taskKey`, then `taskId`, then `issueId`.
|
||||
- `agent_task_sessions` stores session state per company + agent + adapter + task key.
|
||||
- OpenClaw gateway supports `sessionKeyStrategy=issue|fixed|run`, and `issue` already matches the Paperclip mental model well.
|
||||
|
||||
That means "chat with the CEO about this issue" naturally maps to one durable session per issue today without inventing a second session system.
|
||||
|
||||
### Billing behavior
|
||||
|
||||
Billing is already issue-aware.
|
||||
|
||||
- `cost_events` can attach to `issueId`, `projectId`, `goalId`, and `billingCode`.
|
||||
- heartbeat context already propagates issue linkage into runs and cost rollups.
|
||||
|
||||
If chat leaves the issue model, Paperclip would need a second billing story. That is avoidable.
|
||||
|
||||
## UI Kit Recommendation
|
||||
|
||||
## Recommendation: `assistant-ui`
|
||||
|
||||
Use `assistant-ui` as the chat presentation layer.
|
||||
|
||||
Why it fits Paperclip:
|
||||
|
||||
- It is a real chat UI kit, not just a hook.
|
||||
- It is composable and aligned with shadcn-style primitives, which matches the current UI stack well.
|
||||
- It explicitly supports custom backends, which matters because Paperclip talks to agents through issue comments, heartbeats, and run streams rather than direct provider calls.
|
||||
- It gives us polished chat affordances quickly: message list, composer, streaming text, attachments, thread affordances, and markdown-oriented rendering.
|
||||
|
||||
Why not make "the Vercel one" the primary choice:
|
||||
|
||||
- Vercel AI SDK is stronger today than the older "just `useChat` over `/api/chat`" framing. Its transport layer is flexible and can support custom protocols.
|
||||
- But AI SDK is still better understood here as a transport/runtime protocol layer than as the best end-user chat surface for Paperclip.
|
||||
- Paperclip does not need Vercel to own message state, persistence, or the backend contract. Paperclip already has its own issue, run, and session model.
|
||||
|
||||
So the clean split is:
|
||||
|
||||
- `assistant-ui` for UI primitives
|
||||
- Paperclip-owned runtime/store for state, persistence, and transport
|
||||
- optional AI SDK usage later only if we want its stream protocol or client transport abstraction
|
||||
|
||||
## Product Options
|
||||
|
||||
### Option A: Separate chat object
|
||||
|
||||
Create a new top-level chat/thread model unrelated to issues.
|
||||
|
||||
Pros:
|
||||
|
||||
- clean mental model if users want freeform conversation
|
||||
- easy to hide from issue boards
|
||||
|
||||
Cons:
|
||||
|
||||
- breaks the current V1 product decision that communication is issue-centric
|
||||
- needs new persistence, billing, session, permissions, activity, and wakeup rules
|
||||
- creates a second "why does this exist?" object beside issues
|
||||
- makes "pick up an old chat" a separate retrieval problem
|
||||
|
||||
Verdict: not recommended for V1.
|
||||
|
||||
### Option B: Every chat is an issue
|
||||
|
||||
Treat chat as a UI mode over an issue. The issue remains the durable record.
|
||||
|
||||
Pros:
|
||||
|
||||
- matches current product spec
|
||||
- billing, runs, comments, approvals, and activity already work
|
||||
- sessions already resume on issue identity
|
||||
- works with all adapters, including OpenClaw, without new agent auth or a second API surface
|
||||
|
||||
Cons:
|
||||
|
||||
- some chats are not really "tasks" in a board sense
|
||||
- onboarding and review conversations may clutter normal issue lists
|
||||
|
||||
Verdict: best V1 foundation.
|
||||
|
||||
### Option C: Hybrid with hidden conversation issues
|
||||
|
||||
Back every conversation with an issue, but allow a conversation-flavored issue mode that is hidden from default execution boards unless promoted.
|
||||
|
||||
Pros:
|
||||
|
||||
- preserves the issue-centric backend
|
||||
- gives onboarding/review chat a cleaner UX
|
||||
- preserves billing and session continuity
|
||||
|
||||
Cons:
|
||||
|
||||
- requires extra UI rules and possibly a small schema or filtering addition
|
||||
- can become a disguised second system if not kept narrow
|
||||
|
||||
Verdict: likely the right product shape after a basic issue-backed MVP.
|
||||
|
||||
## Recommended Product Model
|
||||
|
||||
### Phase 1 product decision
|
||||
|
||||
For the first implementation, chat should be issue-backed.
|
||||
|
||||
More specifically:
|
||||
|
||||
- the board opens a chat surface for an issue
|
||||
- sending a message is a comment mutation on that issue
|
||||
- the assigned agent is woken through the existing issue-comment flow
|
||||
- streaming output comes from the existing live run stream for that issue
|
||||
- durable assistant output remains comments and run history, not an extra transcript store
|
||||
|
||||
This keeps Paperclip honest about what it is:
|
||||
|
||||
- the control plane stays issue-centric
|
||||
- chat is a better way to interact with issue work, not a new collaboration product
|
||||
|
||||
### Onboarding and CEO conversations
|
||||
|
||||
For onboarding, weekly reviews, and "chat with the CEO", use a conversation issue rather than a global chat tab.
|
||||
|
||||
Suggested shape:
|
||||
|
||||
- create a board-initiated issue assigned to the CEO
|
||||
- mark it as conversation-flavored in UI treatment
|
||||
- optionally hide it from normal issue boards by default later
|
||||
- keep all cost/run/session linkage on that issue
|
||||
|
||||
This solves several concerns at once:
|
||||
|
||||
- no separate API key or direct provider wiring is needed
|
||||
- the same CEO adapter is used
|
||||
- old conversations are recovered through normal issue history
|
||||
- the CEO can still create or update real child issues from the conversation
|
||||
|
||||
## Session Model
|
||||
|
||||
### V1
|
||||
|
||||
Use one durable conversation session per issue.
|
||||
|
||||
That already matches current behavior:
|
||||
|
||||
- adapter task sessions persist against `taskKey`
|
||||
- `taskKey` already falls back to `issueId`
|
||||
- OpenClaw already supports an issue-scoped session key
|
||||
|
||||
This means "resume the CEO conversation later" works by reopening the same issue and waking the same agent on the same issue.
|
||||
|
||||
### What not to add yet
|
||||
|
||||
Do not add multi-thread-per-issue chat in the first pass.
|
||||
|
||||
If Paperclip later needs several parallel threads on one issue, then add an explicit conversation identity and derive:
|
||||
|
||||
- `taskKey = issue:<issueId>:conversation:<conversationId>`
|
||||
- OpenClaw `sessionKey = paperclip:conversation:<conversationId>`
|
||||
|
||||
Until that requirement becomes real, one issue == one durable conversation is the simpler and better rule.
|
||||
|
||||
## Billing Model
|
||||
|
||||
Chat should not invent a separate billing pipeline.
|
||||
|
||||
All chat cost should continue to roll up through the issue:
|
||||
|
||||
- `cost_events.issueId`
|
||||
- project and goal rollups through existing relationships
|
||||
- issue `billingCode` when present
|
||||
|
||||
If a conversation is important enough to exist, it is important enough to have a durable issue-backed audit and cost trail.
|
||||
|
||||
This is another reason ephemeral freeform chat should not be the default.
|
||||
|
||||
## UI Architecture
|
||||
|
||||
### Recommended stack
|
||||
|
||||
1. Keep Paperclip as the source of truth for message history and run state.
|
||||
2. Add `assistant-ui` as the rendering/composer layer.
|
||||
3. Build a Paperclip runtime adapter that maps:
|
||||
- issue comments -> user/assistant messages
|
||||
- live run deltas -> streaming assistant messages
|
||||
- issue attachments -> chat attachments
|
||||
4. Keep current markdown rendering and code-block support where possible.
|
||||
|
||||
### Interaction flow
|
||||
|
||||
1. Board opens issue detail in "Chat" mode.
|
||||
2. Existing comment history is mapped into chat messages.
|
||||
3. When the board sends a message:
|
||||
- `POST /api/issues/{id}/comments`
|
||||
- optionally interrupt the active run if the UX wants "send and replace current response"
|
||||
4. Existing issue comment wakeup logic wakes the assignee.
|
||||
5. Existing `/issues/{id}/live-runs` and `/issues/{id}/active-run` data feeds drive streaming.
|
||||
6. When the run completes, durable state remains in comments/runs/activity as it does now.
|
||||
|
||||
### Why this fits the current code
|
||||
|
||||
Paperclip already has most of the backend pieces:
|
||||
|
||||
- issue comments
|
||||
- run timeline
|
||||
- run log and event streaming
|
||||
- markdown rendering
|
||||
- attachment support
|
||||
- assignee wakeups on comments
|
||||
|
||||
The missing piece is mostly the presentation and the mapping layer, not a new backend domain.
|
||||
|
||||
## Agent Scope
|
||||
|
||||
Do not launch this as "chat with every agent."
|
||||
|
||||
Start narrower:
|
||||
|
||||
- onboarding chat with CEO
|
||||
- workflow/review chat with CEO
|
||||
- maybe selected exec roles later
|
||||
|
||||
Reasons:
|
||||
|
||||
- it keeps the feature from becoming a second inbox/chat product
|
||||
- it limits permission and UX questions early
|
||||
- it matches the stated product demand
|
||||
|
||||
If direct chat with other agents becomes useful later, the same issue-backed pattern can expand cleanly.
|
||||
|
||||
## Recommended Delivery Phases
|
||||
|
||||
### Phase 1: Chat UI on existing issues
|
||||
|
||||
- add a chat presentation mode to issue detail
|
||||
- use `assistant-ui`
|
||||
- map comments + live runs into the chat surface
|
||||
- no schema change
|
||||
- no new API surface
|
||||
|
||||
This is the highest-leverage step because it tests whether the UX is actually useful before product model expansion.
|
||||
|
||||
### Phase 2: Conversation-flavored issues for CEO chat
|
||||
|
||||
- add a lightweight conversation classification
|
||||
- support creation of CEO conversation issues from onboarding and workflow entry points
|
||||
- optionally hide these from normal backlog/board views by default
|
||||
|
||||
The smallest implementation could be a label or issue metadata flag. If it becomes important enough, then promote it to a first-class issue subtype later.
|
||||
|
||||
### Phase 3: Promotion and thread splitting only if needed
|
||||
|
||||
Only if we later see a real need:
|
||||
|
||||
- allow promoting a conversation to a formal task issue
|
||||
- allow several threads per issue with explicit conversation identity
|
||||
|
||||
This should be demand-driven, not designed up front.
|
||||
|
||||
## Clear Recommendation
|
||||
|
||||
If the question is "what should we use?", the answer is:
|
||||
|
||||
- use `assistant-ui` for the chat UI
|
||||
- do not treat raw Vercel AI SDK UI hooks as the main product answer
|
||||
- keep chat issue-backed in V1
|
||||
- use the current issue comment + run + session + billing model rather than inventing a parallel chat subsystem
|
||||
|
||||
If the question is "how should we think about chat in Paperclip?", the answer is:
|
||||
|
||||
- chat is a mode of interacting with issue-backed agent work
|
||||
- not a separate product silo
|
||||
- not an excuse to stop tracing work, cost, and session history back to the issue
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### Immediate implementation target
|
||||
|
||||
The most defensible first build is:
|
||||
|
||||
- add a chat tab or chat-focused layout on issue detail
|
||||
- back it with the currently assigned agent on that issue
|
||||
- use `assistant-ui` primitives over existing comments and live run events
|
||||
|
||||
### Defer these until proven necessary
|
||||
|
||||
- standalone global chat objects
|
||||
- multi-thread chat inside one issue
|
||||
- chat with every agent in the org
|
||||
- a second persistence layer for message history
|
||||
- separate cost tracking for chats
|
||||
|
||||
## References
|
||||
|
||||
- V1 communication model: `doc/SPEC-implementation.md`
|
||||
- Current issue/comment/run UI: `ui/src/pages/IssueDetail.tsx`, `ui/src/components/CommentThread.tsx`, `ui/src/components/LiveRunWidget.tsx`
|
||||
- Session persistence and task key derivation: `server/src/services/heartbeat.ts`, `packages/db/src/schema/agent_task_sessions.ts`
|
||||
- OpenClaw session routing: `packages/adapters/openclaw-gateway/README.md`
|
||||
- assistant-ui docs: <https://www.assistant-ui.com/docs>
|
||||
- assistant-ui repo: <https://github.com/assistant-ui/assistant-ui>
|
||||
- AI SDK transport docs: <https://ai-sdk.dev/docs/ai-sdk-ui/transport>
|
||||
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([
|
||||
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) => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
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,
|
||||
} 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)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Navigate, Outlet, Route, Routes, useLocation } from "@/lib/router";
|
||||
import { Navigate, Outlet, Route, Routes, useLocation, useParams } from "@/lib/router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Layout } from "./components/Layout";
|
||||
@@ -108,6 +108,7 @@ function boardRoutes() {
|
||||
<>
|
||||
<Route index element={<Navigate to="dashboard" replace />} />
|
||||
<Route path="dashboard" element={<Dashboard />} />
|
||||
<Route path="onboarding" element={<OnboardingRoutePage />} />
|
||||
<Route path="companies" element={<Companies />} />
|
||||
<Route path="company/settings" element={<CompanySettings />} />
|
||||
<Route path="settings" element={<LegacySettingsRedirect />} />
|
||||
@@ -164,6 +165,57 @@ function LegacySettingsRedirect() {
|
||||
return <Navigate to={`/instance/settings${location.search}${location.hash}`} replace />;
|
||||
}
|
||||
|
||||
function OnboardingRoutePage() {
|
||||
const { companies, loading } = useCompany();
|
||||
const { onboardingOpen, openOnboarding } = useDialog();
|
||||
const { companyPrefix } = useParams<{ companyPrefix?: string }>();
|
||||
const opened = useRef(false);
|
||||
const matchedCompany = companyPrefix
|
||||
? companies.find((company) => company.issuePrefix.toUpperCase() === companyPrefix.toUpperCase()) ?? null
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
if (loading || opened.current || onboardingOpen) return;
|
||||
opened.current = true;
|
||||
if (matchedCompany) {
|
||||
openOnboarding({ initialStep: 2, companyId: matchedCompany.id });
|
||||
return;
|
||||
}
|
||||
openOnboarding();
|
||||
}, [companyPrefix, loading, matchedCompany, onboardingOpen, openOnboarding]);
|
||||
|
||||
const title = matchedCompany
|
||||
? `Add another agent to ${matchedCompany.name}`
|
||||
: companies.length > 0
|
||||
? "Create another company"
|
||||
: "Create your first company";
|
||||
const description = matchedCompany
|
||||
? "Run onboarding again to add an agent and a starter task for this company."
|
||||
: companies.length > 0
|
||||
? "Run onboarding again to create another company and seed its first agent."
|
||||
: "Get started by creating a company and your first agent.";
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-xl py-10">
|
||||
<div className="rounded-lg border border-border bg-card p-6">
|
||||
<h1 className="text-xl font-semibold">{title}</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">{description}</p>
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
onClick={() =>
|
||||
matchedCompany
|
||||
? openOnboarding({ initialStep: 2, companyId: matchedCompany.id })
|
||||
: openOnboarding()
|
||||
}
|
||||
>
|
||||
{matchedCompany ? "Add Agent" : "Start Onboarding"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CompanyRootRedirect() {
|
||||
const { companies, selectedCompany, loading } = useCompany();
|
||||
const { onboardingOpen } = useDialog();
|
||||
@@ -242,6 +294,7 @@ export function App() {
|
||||
|
||||
<Route element={<CloudAccessGate />}>
|
||||
<Route index element={<CompanyRootRedirect />} />
|
||||
<Route path="onboarding" element={<OnboardingRoutePage />} />
|
||||
<Route path="instance" element={<Navigate to="/instance/settings" replace />} />
|
||||
<Route path="instance/settings" element={<Layout />}>
|
||||
<Route index element={<InstanceSettings />} />
|
||||
|
||||
@@ -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, SOUL.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();
|
||||
@@ -101,6 +107,7 @@ export function OnboardingWizard() {
|
||||
const [forceUnsetAnthropicApiKey, setForceUnsetAnthropicApiKey] =
|
||||
useState(false);
|
||||
const [unsetAnthropicLoading, setUnsetAnthropicLoading] = useState(false);
|
||||
const [showMoreAdapters, setShowMoreAdapters] = useState(false);
|
||||
|
||||
// Step 3
|
||||
const [taskTitle, setTaskTitle] = useState("Create your CEO HEARTBEAT.md");
|
||||
@@ -158,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
|
||||
});
|
||||
@@ -180,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;
|
||||
@@ -218,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 }>>();
|
||||
@@ -233,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]);
|
||||
|
||||
@@ -280,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,
|
||||
@@ -346,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"
|
||||
});
|
||||
@@ -372,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 ?? [];
|
||||
@@ -392,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;
|
||||
}
|
||||
@@ -553,30 +567,38 @@ export function OnboardingWizard() {
|
||||
</button>
|
||||
|
||||
{/* Left half — form */}
|
||||
<div className="w-full md:w-1/2 flex flex-col overflow-y-auto">
|
||||
<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 indicators */}
|
||||
<div className="flex items-center gap-2 mb-8">
|
||||
<Sparkles className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Get Started</span>
|
||||
<span className="text-sm text-muted-foreground/60">
|
||||
Step {step} of 4
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5 ml-auto">
|
||||
{[1, 2, 3, 4].map((s) => (
|
||||
<div
|
||||
key={s}
|
||||
className={cn(
|
||||
"h-1.5 w-6 rounded-full transition-colors",
|
||||
s < step
|
||||
? "bg-green-500"
|
||||
: s === step
|
||||
? "bg-foreground"
|
||||
: "bg-muted"
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{/* 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 }) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => setStep(s)}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-3 py-2 text-xs font-medium border-b-2 -mb-px transition-colors cursor-pointer",
|
||||
s === step
|
||||
? "border-foreground text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground/70 hover:border-border"
|
||||
)}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Step content */}
|
||||
@@ -593,8 +615,15 @@ export function OnboardingWizard() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">
|
||||
<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"
|
||||
)}
|
||||
>
|
||||
Company name
|
||||
</label>
|
||||
<input
|
||||
@@ -605,8 +634,15 @@ export function OnboardingWizard() {
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">
|
||||
<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"
|
||||
)}
|
||||
>
|
||||
Mission / goal (optional)
|
||||
</label>
|
||||
<textarea
|
||||
@@ -665,69 +701,25 @@ export function OnboardingWizard() {
|
||||
icon: Code,
|
||||
desc: "Local Codex agent",
|
||||
recommended: true
|
||||
},
|
||||
{
|
||||
value: "gemini_local" as const,
|
||||
label: "Gemini CLI",
|
||||
icon: Gem,
|
||||
desc: "Local Gemini agent"
|
||||
},
|
||||
{
|
||||
value: "opencode_local" as const,
|
||||
label: "OpenCode",
|
||||
icon: OpenCodeLogoIcon,
|
||||
desc: "Local multi-provider agent"
|
||||
},
|
||||
{
|
||||
value: "pi_local" as const,
|
||||
label: "Pi",
|
||||
icon: Terminal,
|
||||
desc: "Local Pi agent"
|
||||
},
|
||||
{
|
||||
value: "openclaw_gateway" as const,
|
||||
label: "OpenClaw Gateway",
|
||||
icon: Bot,
|
||||
desc: "Invoke OpenClaw via gateway protocol",
|
||||
comingSoon: true,
|
||||
disabledLabel: "Configure OpenClaw within the App"
|
||||
},
|
||||
{
|
||||
value: "cursor" as const,
|
||||
label: "Cursor",
|
||||
icon: MousePointer2,
|
||||
desc: "Local Cursor agent"
|
||||
}
|
||||
].map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
disabled={!!opt.comingSoon}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-1.5 rounded-md border p-3 text-xs transition-colors relative",
|
||||
opt.comingSoon
|
||||
? "border-border opacity-40 cursor-not-allowed"
|
||||
: adapterType === opt.value
|
||||
? "border-foreground bg-accent"
|
||||
: "border-border hover:bg-accent/50"
|
||||
adapterType === opt.value
|
||||
? "border-foreground bg-accent"
|
||||
: "border-border hover:bg-accent/50"
|
||||
)}
|
||||
onClick={() => {
|
||||
if (opt.comingSoon) return;
|
||||
const nextType = opt.value as AdapterType;
|
||||
setAdapterType(nextType);
|
||||
if (nextType === "codex_local" && !model) {
|
||||
setModel(DEFAULT_CODEX_LOCAL_MODEL);
|
||||
} else if (nextType === "gemini_local" && !model) {
|
||||
setModel(DEFAULT_GEMINI_LOCAL_MODEL);
|
||||
} else if (nextType === "cursor" && !model) {
|
||||
setModel(DEFAULT_CURSOR_LOCAL_MODEL);
|
||||
}
|
||||
if (nextType === "opencode_local") {
|
||||
if (!model.includes("/")) {
|
||||
setModel("");
|
||||
}
|
||||
return;
|
||||
if (nextType !== "codex_local") {
|
||||
setModel("");
|
||||
}
|
||||
setModel("");
|
||||
}}
|
||||
>
|
||||
{opt.recommended && (
|
||||
@@ -738,14 +730,105 @@ export function OnboardingWizard() {
|
||||
<opt.icon className="h-4 w-4" />
|
||||
<span className="font-medium">{opt.label}</span>
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
{opt.comingSoon
|
||||
? (opt as { disabledLabel?: string }).disabledLabel ??
|
||||
"Coming soon"
|
||||
: opt.desc}
|
||||
{opt.desc}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
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"
|
||||
)}
|
||||
/>
|
||||
More Agent Adapter Types
|
||||
</button>
|
||||
|
||||
{showMoreAdapters && (
|
||||
<div className="grid grid-cols-2 gap-2 mt-2">
|
||||
{[
|
||||
{
|
||||
value: "gemini_local" as const,
|
||||
label: "Gemini CLI",
|
||||
icon: Gem,
|
||||
desc: "Local Gemini agent"
|
||||
},
|
||||
{
|
||||
value: "opencode_local" as const,
|
||||
label: "OpenCode",
|
||||
icon: OpenCodeLogoIcon,
|
||||
desc: "Local multi-provider agent"
|
||||
},
|
||||
{
|
||||
value: "pi_local" as const,
|
||||
label: "Pi",
|
||||
icon: Terminal,
|
||||
desc: "Local Pi agent"
|
||||
},
|
||||
{
|
||||
value: "cursor" as const,
|
||||
label: "Cursor",
|
||||
icon: MousePointer2,
|
||||
desc: "Local Cursor agent"
|
||||
},
|
||||
{
|
||||
value: "openclaw_gateway" as const,
|
||||
label: "OpenClaw Gateway",
|
||||
icon: Bot,
|
||||
desc: "Invoke OpenClaw via gateway protocol",
|
||||
comingSoon: true,
|
||||
disabledLabel: "Configure OpenClaw within the App"
|
||||
}
|
||||
].map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
disabled={!!opt.comingSoon}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-1.5 rounded-md border p-3 text-xs transition-colors relative",
|
||||
opt.comingSoon
|
||||
? "border-border opacity-40 cursor-not-allowed"
|
||||
: adapterType === opt.value
|
||||
? "border-foreground bg-accent"
|
||||
: "border-border hover:bg-accent/50"
|
||||
)}
|
||||
onClick={() => {
|
||||
if (opt.comingSoon) return;
|
||||
const nextType = opt.value as AdapterType;
|
||||
setAdapterType(nextType);
|
||||
if (nextType === "gemini_local" && !model) {
|
||||
setModel(DEFAULT_GEMINI_LOCAL_MODEL);
|
||||
return;
|
||||
}
|
||||
if (nextType === "cursor" && !model) {
|
||||
setModel(DEFAULT_CURSOR_LOCAL_MODEL);
|
||||
return;
|
||||
}
|
||||
if (nextType === "opencode_local") {
|
||||
if (!model.includes("/")) {
|
||||
setModel("");
|
||||
}
|
||||
return;
|
||||
}
|
||||
setModel("");
|
||||
}}
|
||||
>
|
||||
<opt.icon className="h-4 w-4" />
|
||||
<span className="font-medium">{opt.label}</span>
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
{opt.comingSoon
|
||||
? (opt as { disabledLabel?: string })
|
||||
.disabledLabel ?? "Coming soon"
|
||||
: opt.desc}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Conditional adapter fields */}
|
||||
@@ -824,12 +907,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})
|
||||
@@ -847,8 +933,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>
|
||||
))}
|
||||
@@ -895,75 +986,92 @@ export function OnboardingWizard() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{adapterEnvResult && (
|
||||
{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>
|
||||
</div>
|
||||
) : adapterEnvResult ? (
|
||||
<AdapterEnvironmentResult result={adapterEnvResult} />
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
<div className="rounded-md border border-border/70 bg-muted/20 px-2.5 py-2 text-[11px] space-y-1.5">
|
||||
<p className="font-medium">Manual debug</p>
|
||||
<p className="text-muted-foreground font-mono break-all">
|
||||
{adapterType === "cursor"
|
||||
? `${effectiveAdapterCommand} -p --mode ask --output-format json \"Respond with hello.\"`
|
||||
: adapterType === "codex_local"
|
||||
? `${effectiveAdapterCommand} exec --json -`
|
||||
: adapterType === "gemini_local"
|
||||
? `${effectiveAdapterCommand} --output-format json \"Respond with hello.\"`
|
||||
: adapterType === "opencode_local"
|
||||
? `${effectiveAdapterCommand} run --format json "Respond with hello."`
|
||||
: `${effectiveAdapterCommand} --print - --output-format stream-json --verbose`}
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
Prompt:{" "}
|
||||
<span className="font-mono">Respond with hello.</span>
|
||||
</p>
|
||||
{adapterType === "cursor" || adapterType === "codex_local" || adapterType === "gemini_local" || adapterType === "opencode_local" ? (
|
||||
{adapterEnvResult && adapterEnvResult.status === "fail" && (
|
||||
<div className="rounded-md border border-border/70 bg-muted/20 px-2.5 py-2 text-[11px] space-y-1.5">
|
||||
<p className="font-medium">Manual debug</p>
|
||||
<p className="text-muted-foreground font-mono break-all">
|
||||
{adapterType === "cursor"
|
||||
? `${effectiveAdapterCommand} -p --mode ask --output-format json \"Respond with hello.\"`
|
||||
: adapterType === "codex_local"
|
||||
? `${effectiveAdapterCommand} exec --json -`
|
||||
: adapterType === "gemini_local"
|
||||
? `${effectiveAdapterCommand} --output-format json "Respond with hello."`
|
||||
: adapterType === "opencode_local"
|
||||
? `${effectiveAdapterCommand} run --format json "Respond with hello."`
|
||||
: `${effectiveAdapterCommand} --print - --output-format stream-json --verbose`}
|
||||
</p>
|
||||
<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"
|
||||
Prompt:{" "}
|
||||
<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 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>
|
||||
? "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>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -994,14 +1102,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)}
|
||||
/>
|
||||
@@ -1185,7 +1300,12 @@ export function OnboardingWizard() {
|
||||
</div>
|
||||
|
||||
{/* Right half — ASCII art (hidden on mobile) */}
|
||||
<div className="hidden md:block w-1/2 overflow-hidden">
|
||||
<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>
|
||||
@@ -1203,14 +1323,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}`}>
|
||||
|
||||
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