From 6a66ca1269117600bdfe6b51ea68ade667e15f81 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 10 Aug 2026 22:53:37 +0000 Subject: [PATCH] Fix desktop AI failures and streamline empty-workspace setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cursor dashboard keys are Cloud Agents only — remove the dead local gateway default, surface actionable network errors, and make Test AI save-then-probe with visible status. Empty create can seed the EM; Home shows a single setup checklist instead of an empty command center. Co-authored-by: Damon --- CHANGELOG.md | 2 + apps/api/src/ai.cursor.test.ts | 38 +++-- apps/api/src/ai.ts | 127 ++++++++++----- apps/api/src/index.ts | 84 ++++++++-- apps/ui/src/components/OnboardingWizard.tsx | 163 ++++++++++++-------- apps/ui/src/pages/HomePage.tsx | 64 +++++--- apps/ui/src/pages/SettingsPage.tsx | 129 +++++++++++++--- apps/ui/src/pages/TeamPage.tsx | 40 ++++- apps/ui/src/pages/UnlockPage.tsx | 23 ++- docs/ai/AI_INTEGRATION.md | 13 +- docs/product/FEATURES.md | 2 +- 11 files changed, 499 insertions(+), 186 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e322f9..3948a3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,8 @@ Versions follow [SemVer](https://semver.org/) (`0.1.0-alpha.x` while the public - Committed Tauri `gen/schemas/capabilities.json` aligned with source capabilities (no `shell:allow-open`) - Windows packaging uses NSIS only — WiX `.msi` rejects semver pre-releases like `0.1.0-alpha.1` +- Desktop AI: stop implying Cursor `crsr_` keys are a chat API; clearer network errors; Test saves then probes with visible status +- Empty-workspace onboarding: optional EM name at create, single setup checklist, less Home clutter ## [0.1.0-alpha.1] - TBD diff --git a/apps/api/src/ai.cursor.test.ts b/apps/api/src/ai.cursor.test.ts index 8766194..0fe1b95 100644 --- a/apps/api/src/ai.cursor.test.ts +++ b/apps/api/src/ai.cursor.test.ts @@ -2,23 +2,35 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { defaultCursorBaseUrl, resolveCursorBaseUrl } from "../src/ai.js"; -describe("cursor provider base URL", () => { - it("defaults when stored empty or ollama leftover", () => { - const fallback = defaultCursorBaseUrl(); - assert.equal(resolveCursorBaseUrl(null), fallback); - assert.equal(resolveCursorBaseUrl(""), fallback); - assert.equal(resolveCursorBaseUrl("http://127.0.0.1:11434"), fallback); - assert.equal(resolveCursorBaseUrl("http://127.0.0.1:11434/"), fallback); +describe("cursor / openai-compatible provider base URL", () => { + it("does not invent a silent local gateway when unset", () => { + const prev = process.env.CURSOR_API_BASE_URL; + delete process.env.CURSOR_API_BASE_URL; + try { + assert.equal(defaultCursorBaseUrl(), null); + assert.equal(resolveCursorBaseUrl(null), null); + assert.equal(resolveCursorBaseUrl(""), null); + assert.equal(resolveCursorBaseUrl("http://127.0.0.1:11434"), null); + assert.equal(resolveCursorBaseUrl("http://127.0.0.1:11434/"), null); + } finally { + if (prev === undefined) delete process.env.CURSOR_API_BASE_URL; + else process.env.CURSOR_API_BASE_URL = prev; + } }); - it("keeps an explicit Cursor gateway", () => { + it("keeps an explicit gateway and honors CURSOR_API_BASE_URL", () => { assert.equal( - resolveCursorBaseUrl("http://127.0.0.1:18789/v1/"), - "http://127.0.0.1:18789/v1", - ); - assert.equal( - resolveCursorBaseUrl("https://cursor-gateway.example.com/v1"), + resolveCursorBaseUrl("https://cursor-gateway.example.com/v1/"), "https://cursor-gateway.example.com/v1", ); + const prev = process.env.CURSOR_API_BASE_URL; + process.env.CURSOR_API_BASE_URL = "http://127.0.0.1:18789/v1/"; + try { + assert.equal(defaultCursorBaseUrl(), "http://127.0.0.1:18789/v1"); + assert.equal(resolveCursorBaseUrl(null), "http://127.0.0.1:18789/v1"); + } finally { + if (prev === undefined) delete process.env.CURSOR_API_BASE_URL; + else process.env.CURSOR_API_BASE_URL = prev; + } }); }); diff --git a/apps/api/src/ai.ts b/apps/api/src/ai.ts index 5f2b78a..50288ac 100644 --- a/apps/api/src/ai.ts +++ b/apps/api/src/ai.ts @@ -17,23 +17,50 @@ export type AiFeature = export type ChatMessage = { role: "system" | "user" | "assistant"; content: string }; +function formatFetchFailure(label: string, baseUrl: string, err: unknown): Error { + const detail = err instanceof Error ? err.message : String(err); + const cause = + err instanceof Error && err.cause instanceof Error + ? err.cause.message + : err instanceof Error && err.cause + ? String(err.cause) + : ""; + const combined = [detail, cause].filter(Boolean).join(": "); + const isNetwork = + /fetch failed|ECONNREFUSED|ENOTFOUND|ECONNRESET|network|socket|timed out/i.test(combined); + if (isNetwork) { + return new Error( + `${label} could not reach ${baseUrl} (${combined || "network error"}). ` + + `Cursor dashboard keys (crsr_…) are for Cloud Agents — they are not a chat API. ` + + `Use Anthropic, OpenAI, or Ollama, or point this field at a running OpenAI-compatible /v1 gateway.`, + ); + } + return new Error(`${label} request failed: ${combined || "unknown error"}`); +} + async function callAnthropic(apiKey: string, model: string, messages: ChatMessage[], maxTokens = 1600) { const system = messages.find((m) => m.role === "system")?.content ?? ""; const userMessages = messages.filter((m) => m.role !== "system"); - const res = await fetch("https://api.anthropic.com/v1/messages", { - method: "POST", - headers: { - "content-type": "application/json", - "x-api-key": apiKey, - "anthropic-version": "2023-06-01", - }, - body: JSON.stringify({ - model: model || "claude-sonnet-4-5", - max_tokens: maxTokens, - system, - messages: userMessages.map((m) => ({ role: m.role === "assistant" ? "assistant" : "user", content: m.content })), - }), - }); + const endpoint = "https://api.anthropic.com/v1/messages"; + let res: Response; + try { + res = await fetch(endpoint, { + method: "POST", + headers: { + "content-type": "application/json", + "x-api-key": apiKey, + "anthropic-version": "2023-06-01", + }, + body: JSON.stringify({ + model: model || "claude-sonnet-4-5", + max_tokens: maxTokens, + system, + messages: userMessages.map((m) => ({ role: m.role === "assistant" ? "assistant" : "user", content: m.content })), + }), + }); + } catch (err) { + throw formatFetchFailure("Anthropic", endpoint, err); + } if (!res.ok) { const text = await res.text(); throw new Error(`Anthropic error: ${res.status} ${text.slice(0, 300)}`); @@ -49,18 +76,24 @@ async function callOpenAI( baseUrl = "https://api.openai.com/v1", label = "OpenAI", ) { - const res = await fetch(`${baseUrl.replace(/\/$/, "")}/chat/completions`, { - method: "POST", - headers: { - "content-type": "application/json", - authorization: `Bearer ${apiKey}`, - }, - body: JSON.stringify({ - model: model || "gpt-4o-mini", - messages, - temperature: 0.3, - }), - }); + const endpoint = `${baseUrl.replace(/\/$/, "")}/chat/completions`; + let res: Response; + try { + res = await fetch(endpoint, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model: model || "gpt-4o-mini", + messages, + temperature: 0.3, + }), + }); + } catch (err) { + throw formatFetchFailure(label, endpoint, err); + } if (!res.ok) { const text = await res.text(); throw new Error(`${label} error: ${res.status} ${text.slice(0, 300)}`); @@ -70,11 +103,17 @@ async function callOpenAI( } async function callOllama(baseUrl: string, model: string, messages: ChatMessage[]) { - const res = await fetch(`${baseUrl.replace(/\/$/, "")}/api/chat`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ model: model || "llama3.1", messages, stream: false }), - }); + const endpoint = `${baseUrl.replace(/\/$/, "")}/api/chat`; + let res: Response; + try { + res = await fetch(endpoint, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: model || "llama3.1", messages, stream: false }), + }); + } catch (err) { + throw formatFetchFailure("Ollama", endpoint, err); + } if (!res.ok) { const text = await res.text(); throw new Error(`Ollama error: ${res.status} ${text.slice(0, 300)}`); @@ -85,12 +124,14 @@ async function callOllama(baseUrl: string, model: string, messages: ChatMessage[ const DEFAULT_OLLAMA_BASE = "http://127.0.0.1:11434"; -export function defaultCursorBaseUrl(): string { - return (process.env.CURSOR_API_BASE_URL?.trim() || "http://127.0.0.1:18789/v1").replace(/\/$/, ""); +/** Optional env override for OpenAI-compatible gateways. No silent local default. */ +export function defaultCursorBaseUrl(): string | null { + const fromEnv = process.env.CURSOR_API_BASE_URL?.trim(); + return fromEnv ? fromEnv.replace(/\/$/, "") : null; } -/** Resolve Cursor OpenAI-compatible base URL; ignore leftover Ollama default. */ -export function resolveCursorBaseUrl(stored: string | null | undefined): string { +/** Resolve OpenAI-compatible base URL; ignore leftover Ollama default. */ +export function resolveCursorBaseUrl(stored: string | null | undefined): string | null { const raw = (stored ?? "").trim().replace(/\/$/, ""); if (raw && raw !== DEFAULT_OLLAMA_BASE.replace(/\/$/, "")) return raw; return defaultCursorBaseUrl(); @@ -115,8 +156,8 @@ export function getAiConfig() { return { enabled: Boolean(row?.enabled), provider, - modelDigest: row?.modelDigest ?? (provider === "cursor" ? "composer-2.5" : "claude-sonnet-4-5"), - modelDraft: row?.modelDraft ?? (provider === "cursor" ? "composer-2.5" : "claude-sonnet-4-5"), + modelDigest: row?.modelDigest ?? (provider === "cursor" ? "gpt-4o-mini" : "claude-sonnet-4-5"), + modelDraft: row?.modelDraft ?? (provider === "cursor" ? "gpt-4o-mini" : "claude-sonnet-4-5"), localOnly: Boolean(row?.localOnly), ollamaBaseUrl: row?.ollamaBaseUrl ?? DEFAULT_OLLAMA_BASE, cursorBaseUrl: resolveCursorBaseUrl(row?.ollamaBaseUrl), @@ -152,14 +193,20 @@ export async function runChat(feature: AiFeature, messages: ChatMessage[]) { return { text, model: `openai:${model}`, provider: "openai" as const }; } if (cfg.provider === "cursor") { + if (!cfg.cursorBaseUrl) { + throw new Error( + "OpenAI-compatible base URL required. Cursor dashboard API keys (crsr_…) talk to Cloud Agents, not /v1/chat/completions. " + + "Prefer Anthropic, OpenAI, or Ollama — or set a gateway URL that implements OpenAI Chat Completions.", + ); + } const text = await callOpenAI( cfg.apiKey, - model || "composer-2.5", + model || "gpt-4o-mini", messages, cfg.cursorBaseUrl, - "Cursor", + "OpenAI-compatible", ); - return { text, model: `cursor:${model || "composer-2.5"}`, provider: "cursor" as const }; + return { text, model: `cursor:${model || "gpt-4o-mini"}`, provider: "cursor" as const }; } const text = await callAnthropic(cfg.apiKey, model || "claude-sonnet-4-5", messages, maxTokens); return { text, model: `anthropic:${model}`, provider: "anthropic" as const }; diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 89ab93a..2ccaeda 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -351,7 +351,7 @@ app.get("/api/workspace/status", (c) => { app.post("/api/workspace/init", async (c) => { if (isInitialized()) return c.json({ error: "Already initialized" }, 400); - const body = await c.req.json<{ name?: string; password?: string; seed?: boolean }>(); + const body = await c.req.json<{ name?: string; password?: string; seed?: boolean; emName?: string }>(); const pw = validateWorkspacePassword(body.password); if (!pw.ok) return c.json({ error: pw.error }, 400); // Seed only when explicitly requested — never default to demo data. @@ -367,14 +367,33 @@ app.post("/api/workspace/init", async (c) => { id: wsId, name: body.name || "My Team", createdAt: nowIso(), - settingsJson: "{}", + settingsJson: JSON.stringify({ onboarding: { completed: false, step: "team" } }), passwordHash: hash, passwordSalt: salt, }) .run(); db.insert(aiSettings).values({ id: "default", enabled: 0, provider: "anthropic" }).run(); + const emName = (body.emName ?? "").trim(); + let emPersonId: string | null = null; + if (emName) { + emPersonId = id("person"); + db.insert(people) + .values({ + id: emPersonId, + name: emName, + email: null, + title: "Engineering Manager", + levelKey: "M1", + managerId: null, + hireDate: null, + status: "active", + createdAt: nowIso(), + }) + .run(); + logActivity("person.create", "person", emPersonId, { name: emName, role: "em" }); + } logActivity("workspace.init", "workspace", wsId); - return c.json({ workspaceId: wsId, token: getSessionToken() }); + return c.json({ workspaceId: wsId, token: getSessionToken(), emPersonId }); }); app.post("/api/workspace/unlock", async (c) => { @@ -2183,14 +2202,17 @@ app.get("/api/ai/settings", (c) => { const provider = row?.provider ?? "anthropic"; const hasStoredKey = Boolean(row?.apiKeyEncrypted); const hasEnvCursorKey = provider === "cursor" && Boolean(process.env.CURSOR_API_KEY?.trim()); + const cursorBaseUrl = resolveCursorBaseUrl(row?.ollamaBaseUrl); return c.json({ enabled: Boolean(row?.enabled), provider, modelDigest: row?.modelDigest, modelDraft: row?.modelDraft, localOnly: Boolean(row?.localOnly), - ollamaBaseUrl: row?.ollamaBaseUrl, - cursorBaseUrl: resolveCursorBaseUrl(row?.ollamaBaseUrl), + // For OpenAI-compatible provider, expose the gateway URL in the shared field (never invent 18789). + ollamaBaseUrl: + provider === "cursor" ? cursorBaseUrl ?? "" : (row?.ollamaBaseUrl ?? "http://127.0.0.1:11434"), + cursorBaseUrl, privateNotesEgress: Boolean(row?.privateNotesEgress), meetingSummaryEgress: Boolean(row?.meetingSummaryEgress), documentExtractEgress: Boolean(row?.documentExtractEgress), @@ -3227,22 +3249,66 @@ ${ctx.lines.join("\n")}`, app.post("/api/ai/test", async (c) => { const cfg = getAiConfig(); + const tried = + cfg.localOnly || cfg.provider === "ollama" + ? cfg.ollamaBaseUrl + : cfg.provider === "cursor" + ? cfg.cursorBaseUrl + : cfg.provider === "openai" + ? "https://api.openai.com/v1" + : "https://api.anthropic.com"; try { + if (!cfg.enabled) { + return c.json( + { + ok: false, + error: "AI is disabled — enable AI features, Save, then Test.", + provider: cfg.provider, + baseUrlTried: tried, + }, + 400, + ); + } if (cfg.localOnly || cfg.provider === "ollama") { const text = await runChat("evidence_digest", [ { role: "system", content: "Reply with exactly: OK" }, { role: "user", content: "ping" }, ]); - return c.json({ ok: true, model: text.model, sample: text.text.slice(0, 80) }); + return c.json({ + ok: true, + model: text.model, + sample: text.text.slice(0, 80), + provider: cfg.provider, + baseUrlTried: tried, + }); + } + if (!cfg.hasApiKey) { + return c.json( + { ok: false, error: "No API key on file — paste a key, Save, then Test.", provider: cfg.provider, baseUrlTried: tried }, + 400, + ); } - if (!cfg.hasApiKey) return c.json({ ok: false, error: "No API key" }, 400); const text = await runChat("evidence_digest", [ { role: "system", content: "Reply with exactly: OK" }, { role: "user", content: "ping" }, ]); - return c.json({ ok: true, model: text.model, sample: text.text.slice(0, 80) }); + return c.json({ + ok: true, + model: text.model, + sample: text.text.slice(0, 80), + provider: cfg.provider, + baseUrlTried: tried, + }); } catch (e) { - return c.json({ ok: false, error: e instanceof Error ? e.message : "failed" }, 500); + return c.json( + { + ok: false, + error: e instanceof Error ? e.message : "failed", + provider: cfg.provider, + baseUrlTried: tried, + }, + 500, + ); } }); diff --git a/apps/ui/src/components/OnboardingWizard.tsx b/apps/ui/src/components/OnboardingWizard.tsx index e8ad9f3..2a9e8dd 100644 --- a/apps/ui/src/components/OnboardingWizard.tsx +++ b/apps/ui/src/components/OnboardingWizard.tsx @@ -11,8 +11,10 @@ type Onboarding = { workspaceName: string | null; }; -/** Shown after empty workspace create until team + first cycle exist (or dismissed). */ -export function OnboardingWizard() { +type StepKey = "team" | "cycle" | "done"; + +/** Single checklist after empty workspace create until team + first cycle exist. */ +export function OnboardingWizard({ onActiveChange }: { onActiveChange?: (active: boolean) => void }) { const [data, setData] = useState(null); async function load() { @@ -27,80 +29,113 @@ export function OnboardingWizard() { load(); }, []); + useEffect(() => { + onActiveChange?.(Boolean(data && !data.completed)); + }, [data, onActiveChange]); + if (!data || data.completed) return null; - const step = data.personCount === 0 ? "team" : data.cycleCount === 0 ? "cycle" : "roles"; + const step: StepKey = + data.personCount === 0 ? "team" : data.cycleCount === 0 ? "cycle" : "done"; + + const steps = [ + { key: "team" as const, label: "Add your team", done: data.personCount > 0 }, + { key: "cycle" as const, label: "Open a review cycle", done: data.cycleCount > 0 }, + { key: "done" as const, label: "Assign roles & write", done: step === "done" }, + ]; + + return ( +
+
+

Get set up

+

+ Three steps — then Home becomes your review command center. +

+
+
    + {steps.map((s) => ( +
  1. + {s.done ? "✓ " : s.key === step ? "→ " : ""} + {s.label} +
  2. + ))} +
+ + {step === "team" && ( + + + Open team + + + + } + /> + )} - if (step === "team") { - return ( - - - Add people - + {step === "cycle" && ( + + + Create cycle + + + Edit team + + + } + /> + )} + + {step === "done" && ( + { await api("/api/workspace/settings", { method: "PUT", - body: JSON.stringify({ onboarding: { completed: true, step: "skipped" } }), + body: JSON.stringify({ onboarding: { completed: true, step: "done" } }), }); setData({ ...data, completed: true }); }} > - Dismiss + Go to command center - - } - /> - ); - } - - if (step === "cycle") { - return ( - - - Create cycle - - - Assign roles - - - } - /> - ); - } - - return ( - { - await api("/api/workspace/settings", { - method: "PUT", - body: JSON.stringify({ onboarding: { completed: true, step: "done" } }), - }); - setData({ ...data, completed: true }); - }} - > - Mark setup complete - - } - /> + } + /> + )} +
); } diff --git a/apps/ui/src/pages/HomePage.tsx b/apps/ui/src/pages/HomePage.tsx index 5d3d0ac..e6b57db 100644 --- a/apps/ui/src/pages/HomePage.tsx +++ b/apps/ui/src/pages/HomePage.tsx @@ -45,6 +45,7 @@ export function HomePage() { const [nudgeMsg, setNudgeMsg] = useState(null); const [cycleStats, setCycleStats] = useState(null); const [attention, setAttention] = useState(null); + const [setupActive, setSetupActive] = useState(false); useEffect(() => { api("/api/cycles").then((c) => { @@ -122,26 +123,8 @@ export function HomePage() { }, [items]); const next = (() => { - if (personCount === 0) { - return ( - Open team} - /> - ); - } - if (!cycleId) { - return ( - Create cycle} - /> - ); - } + // OnboardingWizard owns the empty / first-cycle path — avoid duplicate banners. + if (setupActive || personCount === 0 || !cycleId) return null; if (stats.unassigned[0]?.person) { const p = stats.unassigned[0].person; return ( @@ -224,18 +207,50 @@ export function HomePage() { ); })(); + const showSetupOnly = personCount === 0 || (!cycleId && (setupActive || cycles.length === 0)); + return (
Manage cycles & bundles : undefined} + title={showSetupOnly ? "Welcome" : "Command center"} + job={ + showSetupOnly + ? "Set up your team and first cycle — then this page becomes your review pipeline." + : "Unblock the next person in your review pipeline — roles, bundles, then writing." + } + primary={ + !showSetupOnly && cycleId ? ( + + Manage cycles & bundles + + ) : undefined + } /> - + {next} + {showSetupOnly ? ( +
+
    +
  1. + Team — add directs (you’re already EM if you entered your name at create) +
  2. +
  3. + Roles — assign current (+ optional next) role definitions +
  4. +
  5. + Cycles — open a review window and export self/peer bundles +
  6. +
+
+ ) : ( + )}
); } diff --git a/apps/ui/src/pages/SettingsPage.tsx b/apps/ui/src/pages/SettingsPage.tsx index 5263f2c..dbbd49c 100644 --- a/apps/ui/src/pages/SettingsPage.tsx +++ b/apps/ui/src/pages/SettingsPage.tsx @@ -42,6 +42,8 @@ export function SettingsPage({ const [settings, setSettings] = useState(null); const [apiKey, setApiKey] = useState(""); const [msg, setMsg] = useState(null); + const [msgTone, setMsgTone] = useState<"ok" | "danger" | "muted">("muted"); + const [testBusy, setTestBusy] = useState(false); const [error, setError] = useState(null); const [audit, setAudit] = useState(null); const [auditBusy, setAuditBusy] = useState(false); @@ -105,14 +107,44 @@ export function SettingsPage({ !apiKey; async function testAiConnection() { + if (!settings) return; + setTestBusy(true); + setMsg("Saving settings, then testing…"); + setMsgTone("muted"); try { - const res = await api<{ ok: boolean; error?: string; model?: string }>("/api/ai/test", { + await api("/api/ai/settings", { + method: "PUT", + body: JSON.stringify({ + ...settings, + apiKey: apiKey ? apiKey : undefined, + }), + }); + setApiKey(""); + const res = await api<{ + ok: boolean; + error?: string; + model?: string; + provider?: string; + baseUrlTried?: string | null; + }>("/api/ai/test", { method: "POST", body: "{}", }); - setMsg(res.ok ? `Connection OK (${res.model})` : `Failed: ${res.error}`); + if (res.ok) { + setMsgTone("ok"); + setMsg( + `Connection OK (${res.model ?? "unknown"})${res.baseUrlTried ? ` via ${res.baseUrlTried}` : ""}`, + ); + } else { + setMsgTone("danger"); + setMsg(res.error ?? "Test failed"); + } + await load(); } catch (err) { + setMsgTone("danger"); setMsg(err instanceof Error ? err.message : "Test failed"); + } finally { + setTestBusy(false); } } @@ -123,12 +155,34 @@ export function SettingsPage({ job="Appearance, AI, integrations, and private-tier egress. Keys stay encrypted with the workspace secret." primary={} secondary={ - } /> + {msg && ( +

+ {msg} +

+ )} +
Save AI settings} /> ) : settings.enabled ? ( @@ -331,8 +385,13 @@ export function SettingsPage({ label="AI ready for writing desk" detail="Digest and section drafts pull from dossier + imports. Private notes stay off cloud unless you opt in below." action={ - } /> @@ -366,6 +425,7 @@ export function SettingsPage({ }); setApiKey(""); setMsg("Saved"); + setMsgTone("ok"); await load(); }} > @@ -390,12 +450,12 @@ export function SettingsPage({ provider, ...(provider === "cursor" ? { - modelDigest: settings.modelDigest || "composer-2.5", - modelDraft: settings.modelDraft || "composer-2.5", + modelDigest: settings.modelDigest || "gpt-4o-mini", + modelDraft: settings.modelDraft || "gpt-4o-mini", ollamaBaseUrl: settings.ollamaBaseUrl && settings.ollamaBaseUrl !== "http://127.0.0.1:11434" ? settings.ollamaBaseUrl - : settings.cursorBaseUrl || "http://127.0.0.1:18789/v1", + : settings.cursorBaseUrl || "", } : {}), }); @@ -403,16 +463,17 @@ export function SettingsPage({ > - + {settings.provider === "cursor" && (

- Uses an OpenAI-compatible Chat Completions endpoint with your Cursor API key - (crsr_… from cursor.com/dashboard → API Keys). Set Base URL to your Cursor - gateway (local proxy default http://127.0.0.1:18789/v1, or{" "} - CURSOR_API_BASE_URL). Key may also come from CURSOR_API_KEY. + For a local or third-party gateway that speaks OpenAI{" "} + /v1/chat/completions. Cursor dashboard API keys (crsr_…) are for{" "} + Cloud Agents only — they will not work here. Prefer Anthropic, OpenAI, or + Ollama unless you already run a compatible proxy. Optional env:{" "} + CURSOR_API_BASE_URL / CURSOR_API_KEY.

)}
@@ -429,21 +490,24 @@ export function SettingsPage({ value={apiKey} onChange={(e) => setApiKey(e.target.value)} placeholder={ - settings.hasApiKey ? "••••••••" : settings.provider === "cursor" ? "crsr_…" : "sk-…" + settings.hasApiKey ? "••••••••" : settings.provider === "cursor" ? "gateway key…" : "sk-…" } />
{(settings.provider === "ollama" || settings.provider === "cursor") && (
- + setSettings({ ...settings, ollamaBaseUrl: e.target.value })} placeholder={ settings.provider === "cursor" - ? "http://127.0.0.1:18789/v1" + ? "https://your-gateway.example/v1" : "http://127.0.0.1:11434" } + required={settings.provider === "cursor"} />
)} @@ -452,7 +516,7 @@ export function SettingsPage({ setSettings({ ...settings, modelDigest: e.target.value })} - placeholder={settings.provider === "cursor" ? "composer-2.5" : undefined} + placeholder={settings.provider === "cursor" ? "gpt-4o-mini" : undefined} />
@@ -460,7 +524,7 @@ export function SettingsPage({ setSettings({ ...settings, modelDraft: e.target.value })} - placeholder={settings.provider === "cursor" ? "composer-2.5" : undefined} + placeholder={settings.provider === "cursor" ? "gpt-4o-mini" : undefined} />
@@ -498,11 +562,30 @@ export function SettingsPage({ ))}
-
- {msg &&

{msg}

} + {msg && ( +

+ {msg} +

+ )}
diff --git a/apps/ui/src/pages/TeamPage.tsx b/apps/ui/src/pages/TeamPage.tsx index 2c46e2d..4b68cd5 100644 --- a/apps/ui/src/pages/TeamPage.tsx +++ b/apps/ui/src/pages/TeamPage.tsx @@ -73,17 +73,49 @@ export function TeamPage() { {people.length === 0 && (
-

No one on the roster yet

+

Add your team

- Add yourself first (leave manager blank — you become the EM), then add directs. Or paste a CSV - below. Empty workspaces start here before roles and cycles. + First person without a manager becomes the EM. Prefer empty-workspace create with your name + filled in — then use this page to add directs or paste a CSV.

)} + {people.length > 0 && !em && ( + Add EM} + /> + )} + + {em && directs.length === 0 && ( + + + Add direct + + + Import CSV + + + } + /> + )} + {unassigned.length > 0 && ( ("empty"); const [password, setPassword] = useState(""); const [name, setName] = useState("My team"); + const [emName, setEmName] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [eraseOpen, setEraseOpen] = useState(false); @@ -46,6 +47,9 @@ export function UnlockPage({ name: name.trim() || "My team", password, seed: setupMode === "demo", + ...(setupMode === "empty" && emName.trim() + ? { emName: emName.trim() } + : {}), }), }); await onUnlocked(res.token); @@ -115,7 +119,7 @@ export function UnlockPage({ onClick={() => selectMode("empty")} > Empty workspace - Start clean. Add your people, roles, and cycle when you are ready. + Start clean — enter your name as EM, then add directs on Team.