diff --git a/doc/plans/agent-chat-ui-and-issue-backed-conversations.md b/doc/plans/agent-chat-ui-and-issue-backed-conversations.md new file mode 100644 index 00000000..7364b6d0 --- /dev/null +++ b/doc/plans/agent-chat-ui-and-issue-backed-conversations.md @@ -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::conversation:` +- OpenClaw `sessionKey = paperclip:conversation:` + +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: +- assistant-ui repo: +- AI SDK transport docs: diff --git a/server/src/__tests__/issue-goal-fallback.test.ts b/server/src/__tests__/issue-goal-fallback.test.ts new file mode 100644 index 00000000..cae1b8ab --- /dev/null +++ b/server/src/__tests__/issue-goal-fallback.test.ts @@ -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(); + }); +}); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 9c91fec4..f02067a6 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -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) => { diff --git a/server/src/services/goals.ts b/server/src/services/goals.ts index 9045ebc1..aa5db493 100644 --- a/server/src/services/goals.ts +++ b/server/src/services/goals.ts @@ -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; + +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) => db .insert(goals) diff --git a/server/src/services/issue-goal-fallback.ts b/server/src/services/issue-goal-fallback.ts new file mode 100644 index 00000000..fe48f0a1 --- /dev/null +++ b/server/src/services/issue-goal-fallback.ts @@ -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; +} diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 29995cd4..807a97eb 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -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 | 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) diff --git a/ui/src/App.tsx b/ui/src/App.tsx index ed6c9c51..1cfdd9df 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -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() { <> } /> } /> + } /> } /> } /> } /> @@ -164,6 +165,57 @@ function LegacySettingsRedirect() { return ; } +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 ( +
+
+

{title}

+

{description}

+
+ +
+
+
+ ); +} + function CompanyRootRedirect() { const { companies, selectedCompany, loading } = useCompany(); const { onboardingOpen } = useDialog(); @@ -242,6 +294,7 @@ export function App() { }> } /> + } /> } /> }> } /> diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index a043655f..5d166929 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -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>(); @@ -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() { {/* Left half — form */} -
+
- {/* Progress indicators */} -
- - Get Started - - Step {step} of 4 - -
- {[1, 2, 3, 4].map((s) => ( -
- ))} -
+ {/* Progress tabs */} +
+ {( + [ + { 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 content */} @@ -593,8 +615,15 @@ export function OnboardingWizard() {

-
-