diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 156ba405..6d857cf3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,7 +23,7 @@ bun run db:migrate # 6. Start the dashboard bun run dev:web -# → http://localhost:3000 +# → http://localhost:3003 ``` ## Prerequisites @@ -74,7 +74,7 @@ Run from the **repo root** unless noted. | Command | What it starts | |---------|---------------| | `bun run dev` | All dev servers (Turborepo) | -| `bun run dev:web` | Dashboard only (port 3000) | +| `bun run dev:web` | Dashboard and Nova Web (port 3003) | | `bun run dev:docs` | Docs site (port 3001) | | `bun run dev:terminal` | Terminal web client | | `bun run dev:terminal-server` | CLI agent dev loop | @@ -124,7 +124,7 @@ Example for local development: ```env DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres" BETTER_AUTH_SECRET="your-secret" # openssl rand -hex 32 -BETTER_AUTH_URL="http://localhost:3000" +BETTER_AUTH_URL="http://localhost:3003" GITHUB_CLIENT_ID="your-github-client-id" # from github.com/settings/developers GITHUB_CLIENT_SECRET="your-github-secret" ``` diff --git a/README.md b/README.md index 5f99f9b0..2e105706 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ bun run db:migrate # create database tables bun run dev:web # start the dashboard ``` -> **Dashboard** → http://localhost:3000 +> **Dashboard / Nova Web** → http://localhost:3003 / http://nova.localhost:3003 No database? Set one up later — `bun install` skips Prisma client generation gracefully when `DATABASE_URL` isn't set. @@ -81,9 +81,9 @@ This is a **monorepo** managed with [Turborepo](https://turbo.build) and [Bun](h | App | Description | Port | |-----|-------------|------| -| `apps/web` | Next.js 16 dashboard (supercli.com) | `3000` | +| `apps/web` | Next.js 16 dashboard and Nova Web | `3003` | | `apps/docs` | Next.js MDX documentation site | `3001` | -| `apps/supercode-cli/client` | Terminal web client UI (Next.js) | `3002` | +| `apps/supercode-cli/client` | Terminal web client UI and Nova login | `3000` | | `apps/supercode-cli/server` | AI coding agent — also published as the `supercode` npm CLI | — | | `apps/api` | Shared API server (scaffolded) | TBD | @@ -271,7 +271,7 @@ We use high-quality coding datasets: ```env DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres" BETTER_AUTH_SECRET="your-secret-key" # openssl rand -hex 32 - BETTER_AUTH_URL="http://localhost:3000" + BETTER_AUTH_URL="http://localhost:3003" GITHUB_CLIENT_ID="your-github-oauth-id" GITHUB_CLIENT_SECRET="your-github-oauth-secret" ``` @@ -290,7 +290,7 @@ We use high-quality coding datasets: ```bash bun run dev:web ``` - Open [http://localhost:3000](http://localhost:3000). + Open [http://localhost:3003](http://localhost:3003), or Nova at [http://nova.localhost:3003](http://nova.localhost:3003). 7. **Start other apps** (in separate terminals) ```bash @@ -403,7 +403,7 @@ Run from the **repo root** unless otherwise noted. | Script | What it does | |--------|--------------| | `bun run dev` | Start all dev servers (Turborepo) | -| `bun run dev:web` | Dashboard only (port 3000) | +| `bun run dev:web` | Dashboard and Nova Web (port 3003) | | `bun run dev:docs` | Docs only (port 3001) | | `bun run dev:terminal` | Terminal web client only | | `bun run dev:terminal-server` | CLI agent dev loop | diff --git a/apps/supercode-cli/client/components/auth/login-form.tsx b/apps/supercode-cli/client/components/auth/login-form.tsx index 0edc8796..56f6442b 100644 --- a/apps/supercode-cli/client/components/auth/login-form.tsx +++ b/apps/supercode-cli/client/components/auth/login-form.tsx @@ -9,6 +9,23 @@ import { Github, Code2, Sparkles, ArrowRight } from 'lucide-react' import { ParticleBackground } from './particle-background' import { PixelLogo } from '@/components/ui/pixel-logo' +const ALLOWED_REDIRECT_ORIGINS = new Set([ + "https://nova.supercodeai.tech", + "http://nova.localhost:3003", +]) + +function getSafeRedirect(): URL | null { + const redirect = new URLSearchParams(window.location.search).get("redirect") + if (!redirect) return null + + try { + const url = new URL(redirect) + return ALLOWED_REDIRECT_ORIGINS.has(url.origin) ? url : null + } catch { + return null + } +} + const LoginForm = () => { const router = useRouter() const [isLoading, setIsLoading] = useState(false) @@ -18,10 +35,32 @@ const LoginForm = () => { const { data, isPending } = authClient.useSession() useEffect(() => { - if (!isPending && data?.session) { - const params = new URLSearchParams(window.location.search) - const redirect = params.get("redirect") - router.replace(redirect || "/") + if (isPending || !data?.session) return + + const novaUrl = getSafeRedirect() + if (!novaUrl) { + router.replace("/") + return + } + + let cancelled = false + const transferSession = async () => { + const { data: tokenData, error: tokenError } = await authClient.oneTimeToken.generate() + if (cancelled) return + if (tokenError || !tokenData?.token) { + setError("Failed to create the Nova login session. Please try again.") + return + } + + const callbackUrl = new URL("/api/auth/cli/callback", novaUrl.origin) + callbackUrl.searchParams.set("token", tokenData.token) + callbackUrl.searchParams.set("redirect", novaUrl.pathname) + window.location.replace(callbackUrl.toString()) + } + + void transferSession() + return () => { + cancelled = true } }, [data, isPending, router]) @@ -46,10 +85,9 @@ const LoginForm = () => { setIsLoading(true) setError(null) try { - const params = new URLSearchParams(window.location.search) - const redirect = params.get("redirect") || "" - const callbackURL = redirect - ? new URL(redirect, window.location.origin).toString() + const novaUrl = getSafeRedirect() + const callbackURL = novaUrl + ? `${window.location.origin}/sign-in?redirect=${encodeURIComponent(novaUrl.toString())}` : window.location.origin await authClient.signIn.social({ provider: 'github', diff --git a/apps/supercode-cli/client/lib/auth-client.ts b/apps/supercode-cli/client/lib/auth-client.ts index b31e299e..2a61c541 100644 --- a/apps/supercode-cli/client/lib/auth-client.ts +++ b/apps/supercode-cli/client/lib/auth-client.ts @@ -1,8 +1,13 @@ -import { createAuthClient } from "better-auth/react"; -import {deviceAuthorizationClient} from 'better-auth/client/plugins' +import { createAuthClient } from "better-auth/react" +import { + deviceAuthorizationClient, + oneTimeTokenClient, +} from "better-auth/client/plugins" + export const authClient = createAuthClient({ - baseURL: process.env.NEXT_PUBLIC_AUTH_URL || "http://localhost:3004", - plugins:[ - deviceAuthorizationClient() - ] -}) \ No newline at end of file + baseURL: process.env.NEXT_PUBLIC_AUTH_URL || "http://localhost:3004", + plugins: [ + deviceAuthorizationClient(), + oneTimeTokenClient(), + ], +}) diff --git a/apps/supercode-cli/server/.env.example b/apps/supercode-cli/server/.env.example index e1ebc93d..5be0ff07 100644 --- a/apps/supercode-cli/server/.env.example +++ b/apps/supercode-cli/server/.env.example @@ -39,6 +39,10 @@ GITHUB_REDIRECT_URI="" PORT=10000 # 🔴 CLI auth URL (used by `supercode login`) SUPERCODE_SERVER_URL="http://localhost:10000" +NOVA_WEB_URL="http://nova.localhost:3003" + +# Composio connections for CLI, Nova desktop, and Nova web (not Review integrations). +COMPOSIO_API_KEY="" # ⚪ Default workspace root for file operations SUPERCODE_WORKSPACE_ROOT="" diff --git a/apps/supercode-cli/server/src/index.ts b/apps/supercode-cli/server/src/index.ts index b18ce0ad..21148c99 100644 --- a/apps/supercode-cli/server/src/index.ts +++ b/apps/supercode-cli/server/src/index.ts @@ -497,6 +497,8 @@ app.get("/api/nova/sessions", proxyDesktopNova) app.post("/api/nova/sessions", proxyDesktopNova) app.get("/api/nova/sessions/:sessionId", proxyDesktopNova) app.get("/api/nova/sessions/:sessionId/sync", proxyDesktopNova) +app.post("/api/nova/sessions/:sessionId/messages", proxyDesktopNova) +app.post("/api/nova/sessions/:sessionId/turn", proxyDesktopNova) app.get("/api/user/me", async (req, res) => { try { diff --git a/apps/supercode-cli/server/src/lib/auth.ts b/apps/supercode-cli/server/src/lib/auth.ts index 53245cf8..2d846335 100644 --- a/apps/supercode-cli/server/src/lib/auth.ts +++ b/apps/supercode-cli/server/src/lib/auth.ts @@ -1,12 +1,16 @@ import { betterAuth } from "better-auth" import { prismaAdapter } from "better-auth/adapters/prisma" -import { deviceAuthorization } from "better-auth/plugins" +import { deviceAuthorization, oneTimeToken } from "better-auth/plugins" import prisma from "./prisma" const serverUrl = process.env.BETTER_AUTH_URL || "http://localhost:3004" const clientUrl = process.env.CLIENT_URL || "http://localhost:3000" const isProduction = serverUrl.startsWith("https://") +const novaOrigins = [ + "https://nova.supercodeai.tech", + "http://nova.localhost:3003", +] export const auth = betterAuth({ database: prismaAdapter(prisma, { @@ -14,7 +18,7 @@ export const auth = betterAuth({ }), baseURL: serverUrl, basePath: "/api/auth", - trustedOrigins: [clientUrl, serverUrl], + trustedOrigins: [clientUrl, serverUrl, ...novaOrigins], account: { skipStateCookieCheck: true, }, @@ -33,6 +37,10 @@ export const auth = betterAuth({ }, }, plugins: [ + oneTimeToken({ + expiresIn: 3, + storeToken: "hashed", + }), deviceAuthorization({ schema: {}, expiresIn: "10m", diff --git a/apps/supercode-cli/server/src/lib/composio.ts b/apps/supercode-cli/server/src/lib/composio.ts new file mode 100644 index 00000000..b44ec407 --- /dev/null +++ b/apps/supercode-cli/server/src/lib/composio.ts @@ -0,0 +1,154 @@ +import { Composio } from "@composio/core" + +const READ_ONLY_TOOL_PATTERN = /(^|_)(CHECK|DESCRIBE|DOWNLOAD|FETCH|FIND|GET|LIST|LOOKUP|QUERY|READ|RETRIEVE|SEARCH|VIEW)(_|$)/i + +export class ServerComposioService { + constructor(private client?: Composio) {} + + private get composio(): Composio { + if (this.client) return this.client + const apiKey = process.env.COMPOSIO_API_KEY?.trim() + if (!apiKey) { + throw Object.assign(new Error("COMPOSIO_API_KEY is not configured on the CLI server"), { + statusCode: 503, + }) + } + this.client = new Composio({ apiKey }) + return this.client + } + + private async activeAccounts(userId: string) { + const accounts = [] as Awaited>["items"] + let cursor: string | undefined + do { + const page = await this.composio.connectedAccounts.list({ + userIds: [userId], + statuses: ["ACTIVE"], + accountType: "PRIVATE", + cursor, + limit: 100, + }) + accounts.push(...page.items.filter((account) => account.status === "ACTIVE" && !account.isDisabled)) + cursor = page.nextCursor ?? undefined + } while (cursor) + return accounts + } + + private async authConfigs(toolkit?: string) { + const configs = [] as Awaited>["items"] + let cursor: string | undefined + do { + const page = await this.composio.authConfigs.list({ toolkit, showDisabled: false, cursor, limit: 100 }) + configs.push(...page.items.filter((config) => config.status === "ENABLED")) + cursor = page.nextCursor ?? undefined + } while (cursor) + return configs + } + + async listApps(userId: string) { + const [toolkits, configs, accounts] = await Promise.all([ + this.composio.toolkits.get(), + this.authConfigs(), + this.activeAccounts(userId), + ]) + const configured = new Set(configs.map((config) => config.toolkit.slug)) + const connected = new Map(accounts.map((account) => [account.toolkit.slug, account.id])) + return toolkits + .filter((toolkit) => configured.has(toolkit.slug) || connected.has(toolkit.slug)) + .map((toolkit) => ({ + slug: toolkit.slug, + name: toolkit.name, + description: toolkit.meta?.description ?? "", + logo: toolkit.meta?.logo ?? null, + connected: connected.has(toolkit.slug), + connectedAccountId: connected.get(toolkit.slug) ?? null, + })) + .sort((a, b) => a.connected === b.connected + ? a.name.localeCompare(b.name) + : a.connected ? -1 : 1) + } + + async createSession(userId: string) { + const accounts = await this.activeAccounts(userId) + const connectedAccounts = Object.fromEntries(accounts.map((account) => [account.toolkit.slug, account.id])) + const session = await this.composio.sessions.create(userId, { + mcp: true, + connectedAccounts, + toolkits: Object.keys(connectedAccounts), + manageConnections: false, + }) + return { + url: session.mcp.url, + headers: session.mcp.headers, + sessionId: session.sessionId, + } + } + + async connect(userId: string, slug: string, callbackUrl: string) { + const accounts = await this.activeAccounts(userId) + const existing = accounts.find((account) => account.toolkit.slug === slug) + if (existing) return { connectedAccountId: existing.id, redirectUrl: null } + + const configs = await this.authConfigs(slug) + const envId = process.env[`COMPOSIO_${slug.toUpperCase()}_AUTH_CONFIG_ID`]?.trim() + const config = configs.find((candidate) => candidate.id === envId) + ?? configs.find((candidate) => candidate.toolkit.slug === slug) + const configId = config?.id ?? (await this.composio.authConfigs.create(slug, { + type: "use_composio_managed_auth", + name: `Supercode ${slug}`, + })).id + const connection = await this.composio.connectedAccounts.link(userId, configId, { callbackUrl }) + if (!connection.redirectUrl) throw new Error("Composio did not return an authorization URL") + return { connectedAccountId: connection.id, redirectUrl: connection.redirectUrl } + } + + async verifyConnection(userId: string, slug: string, connectedAccountId: string) { + const accounts = await this.activeAccounts(userId) + return accounts.some((account) => account.id === connectedAccountId && account.toolkit.slug === slug) + } + + async disconnect(userId: string, connectedAccountId: string) { + const accounts = await this.activeAccounts(userId) + if (!accounts.some((account) => account.id === connectedAccountId)) { + throw Object.assign(new Error("Connected account not found"), { statusCode: 404 }) + } + await this.composio.connectedAccounts.delete(connectedAccountId) + } + + async listTools(userId: string) { + const accounts = await this.activeAccounts(userId) + const toolkits = [...new Set(accounts.map((account) => account.toolkit.slug))] + if (toolkits.length === 0) return [] + const raw = await this.composio.tools.getRawComposioTools({ toolkits }) + return raw.filter((tool) => !tool.isDeprecated && tool.toolkit && toolkits.includes(tool.toolkit.slug)).map((tool) => ({ + name: tool.slug, + displayName: tool.name, + description: tool.description || tool.name, + parameters: tool.inputParameters ?? { type: "object", properties: {} }, + toolkit: tool.toolkit?.slug, + toolkitName: tool.toolkit?.name, + requiresApproval: !READ_ONLY_TOOL_PATTERN.test(tool.slug), + })) + } + + async executeTool(userId: string, name: string, args: Record) { + const tools = await this.composio.tools.getRawComposioTools({ tools: [name] }) + const tool = tools.find((candidate) => candidate.slug === name && !candidate.isDeprecated) + if (!tool?.toolkit) throw Object.assign(new Error("Composio tool not found"), { statusCode: 404 }) + const toolkit = tool.toolkit.slug + const accounts = await this.activeAccounts(userId) + const account = accounts.find((candidate) => candidate.toolkit.slug === toolkit) + if (!account) { + throw Object.assign(new Error(`Connect ${toolkit} before using this tool`), { statusCode: 403 }) + } + const version = process.env[`COMPOSIO_TOOLKIT_VERSION_${toolkit.toUpperCase()}`]?.trim() + || process.env.COMPOSIO_TOOLKIT_VERSION?.trim() + || "latest" + return this.composio.tools.execute(name, { + userId, + connectedAccountId: account.id, + arguments: args, + version, + }) + } +} diff --git a/apps/supercode-cli/server/src/routes/composio-execute.test.ts b/apps/supercode-cli/server/src/routes/composio-execute.test.ts new file mode 100644 index 00000000..13f18241 --- /dev/null +++ b/apps/supercode-cli/server/src/routes/composio-execute.test.ts @@ -0,0 +1,43 @@ +import { afterAll, expect, mock, test } from "bun:test" +import express from "express" +import type { Composio } from "@composio/core" + +import { ServerComposioService } from "../lib/composio" +import { registerComposioRoutes } from "./composio" + +const execute = mock(async (_name: string, _args: unknown) => ({ successful: true, data: { workspace: "Connected workspace" } })) +const client = { + connectedAccounts: { list: async () => ({ items: [{ id: "ca_linear", status: "ACTIVE", isDisabled: false, toolkit: { slug: "linear" } }], nextCursor: null }) }, + tools: { + getRawComposioTools: async () => [{ slug: "LINEAR_GET_WORKSPACE", toolkit: { slug: "linear" } }], + execute, + }, +} as unknown as Composio +const app = express() +app.use(express.json()) +registerComposioRoutes(app, async () => ({ id: "cli_user" }), new ServerComposioService(client)) +const server = app.listen(0, "127.0.0.1") +await new Promise((resolve) => server.once("listening", () => resolve())) +const address = server.address() +if (!address || typeof address === "string") throw new Error("No test server address") +const url = `http://127.0.0.1:${address.port}/api/composio/execute` +afterAll(() => { server.closeAllConnections(); server.close() }) + +async function post(body: unknown) { + return fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }) +} + +test("accepts desktop’s toolName execute contract", async () => { + const response = await post({ toolName: "LINEAR_GET_WORKSPACE", arguments: {} }) + expect(response.status).toBe(200) + expect((await response.json()).successful).toBe(true) + expect(execute).toHaveBeenCalledWith("LINEAR_GET_WORKSPACE", { + userId: "cli_user", connectedAccountId: "ca_linear", arguments: {}, version: "latest", + }) +}) + +test("keeps name compatibility and rejects conflicting names", async () => { + expect((await post({ name: "LINEAR_GET_WORKSPACE", arguments: {} })).status).toBe(200) + expect((await post({ toolName: "LINEAR_GET_WORKSPACE", name: "OTHER_TOOL", arguments: {} })).status).toBe(400) + expect((await post({ arguments: {} })).status).toBe(400) +}) diff --git a/apps/supercode-cli/server/src/routes/composio.test.ts b/apps/supercode-cli/server/src/routes/composio.test.ts new file mode 100644 index 00000000..05e6b15e --- /dev/null +++ b/apps/supercode-cli/server/src/routes/composio.test.ts @@ -0,0 +1,201 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test" +import type { Composio } from "@composio/core" +import express from "express" +import type { Server } from "node:http" + +import { ServerComposioService } from "../lib/composio" +import { registerComposioRoutes } from "./composio" + +const originalSecret = process.env.BETTER_AUTH_SECRET +const originalUrl = process.env.BETTER_AUTH_URL +const originalNovaUrl = process.env.NOVA_WEB_URL + +describe("CLI Composio connections", () => { + let server: Server + let baseUrl: string + let accounts: Array<{ id: string; owner: string; status: string; toolkit: { slug: string }; isDisabled: boolean }> + let callbackUrl: string + let service: ServerComposioService + let client: ReturnType + + function createClient() { + return { + connectedAccounts: { + list: mock(async (query: { userIds: string[]; cursor?: string; statuses: string[] }) => ({ + items: accounts.filter((account) => query.userIds.includes(account.owner) && query.statuses.includes(account.status)), + nextCursor: null as string | null, + })), + link: mock(async (userId: string, _configId: string, options: { callbackUrl: string }) => { + callbackUrl = options.callbackUrl + accounts.push({ id: "ca_new", owner: userId, status: "INITIATED", toolkit: { slug: "linear" }, isDisabled: false }) + return { id: "ca_new", redirectUrl: "https://connect.composio.dev/authorize" } + }), + delete: mock(async (id: string) => { accounts = accounts.filter((account) => account.id !== id) }), + }, + authConfigs: { + list: mock(async (query: { toolkit?: string; cursor?: string }) => ({ + items: ["github", "googlecalendar", "linear"].filter((slug) => !query.toolkit || query.toolkit === slug) + .map((slug) => ({ id: `ac_${slug}`, toolkit: { slug }, status: "ENABLED" })), + nextCursor: null as string | null, + })), + create: mock(async () => ({ id: "ac_managed" })), + }, + toolkits: { + get: mock(async () => ["github", "googlecalendar", "linear"].map((slug) => ({ slug, name: slug, meta: {} }))), + }, + sessions: { + create: mock(async (_userId: string, _options: unknown) => ({ sessionId: "session_1", mcp: { url: "https://mcp.composio.dev/session_1", headers: { "x-mcp-secret": "test-only" } } })), + }, + tools: { + getRawComposioTools: mock(async () => [ + { slug: "GOOGLECALENDAR_LIST_EVENTS", name: "List events", toolkit: { slug: "googlecalendar", name: "Calendar" } }, + { slug: "GOOGLECALENDAR_CREATE_EVENT", name: "Create event", toolkit: { slug: "googlecalendar", name: "Calendar" } }, + { slug: "GITHUB_CREATE_ISSUE", name: "Create issue", toolkit: { slug: "github", name: "GitHub" } }, + ]), + execute: mock(async (_name: string, _options: unknown) => ({ successful: true, data: {} })), + }, + } + } + + async function post(action: string, body: unknown = {}, token = "user_1") { + return fetch(`${baseUrl}/api/composio/${action}`, { + method: "POST", + headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, + body: JSON.stringify(body), + }) + } + + beforeEach(async () => { + process.env.BETTER_AUTH_SECRET = "test-cli-composio-secret" + process.env.BETTER_AUTH_URL = "http://localhost:3004" + process.env.NOVA_WEB_URL = "http://nova.localhost:3003" + accounts = [ + { id: "ca_calendar", owner: "user_1", status: "ACTIVE", toolkit: { slug: "googlecalendar" }, isDisabled: false }, + { id: "ca_review", owner: "org_review", status: "ACTIVE", toolkit: { slug: "github" }, isDisabled: false }, + { id: "ca_other", owner: "user_2", status: "ACTIVE", toolkit: { slug: "github" }, isDisabled: false }, + ] + callbackUrl = "" + client = createClient() + service = new ServerComposioService(client as unknown as Composio) + const app = express() + app.use(express.json()) + registerComposioRoutes(app, async (req) => { + const token = req.headers.authorization?.replace("Bearer ", "") + return token === "user_1" || token === "user_2" ? { id: token } : null + }, service) + await new Promise((resolve) => { server = app.listen(0, "127.0.0.1", () => resolve()) }) + const address = server.address() + if (!address || typeof address === "string") throw new Error("No test server address") + baseUrl = `http://127.0.0.1:${address.port}` + }) + + afterEach(async () => { + server.closeAllConnections() + await new Promise((resolve) => server.close(() => resolve())) + for (const [key, value] of [["BETTER_AUTH_SECRET", originalSecret], ["BETTER_AUTH_URL", originalUrl], ["NOVA_WEB_URL", originalNovaUrl]]) { + if (!key) continue + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + }) + + test("lists CLI-configured apps beyond Review providers, scoped to the authenticated user", async () => { + const response = await post("apps") + expect(response.status).toBe(200) + const payload = await response.json() + expect(payload.apps).toHaveLength(3) + expect(payload.apps.find((app: { slug: string }) => app.slug === "googlecalendar").connectedAccountId).toBe("ca_calendar") + expect(payload.apps.find((app: { slug: string }) => app.slug === "github").connected).toBe(false) + expect(client.connectedAccounts.list.mock.calls[0]?.[0].userIds).toEqual(["user_1"]) + }) + + test("creates MCP sessions only from the same user’s CLI accounts", async () => { + const response = await post("session") + expect(response.status).toBe(200) + expect((await response.json()).sessionId).toBe("session_1") + expect(client.sessions.create).toHaveBeenCalledWith("user_1", { + mcp: true, connectedAccounts: { googlecalendar: "ca_calendar" }, toolkits: ["googlecalendar"], manageConnections: false, + }) + }) + + test("web OAuth returns to Nova and the new account appears for desktop too", async () => { + const connection = await post("connect", { slug: "linear", returnTo: "nova" }) + expect(connection.status).toBe(200) + expect((await connection.json()).connectedAccountId).toBe("ca_new") + expect(new URL(callbackUrl).pathname).toBe("/api/composio/callback") + const account = accounts.find((candidate) => candidate.id === "ca_new") + if (!account) throw new Error("Missing connected account") + account.status = "ACTIVE" + const callback = new URL(callbackUrl) + callback.searchParams.set("connected_account_id", "ca_new") + callback.searchParams.set("status", "success") + const completion = await fetch(`${baseUrl}${callback.pathname}${callback.search}`, { redirect: "manual" }) + expect(completion.status).toBe(303) + expect(completion.headers.get("location")).toBe("http://nova.localhost:3003/connections?connected=linear") + const desktop = await post("apps") + expect((await desktop.json()).apps.find((app: { slug: string }) => app.slug === "linear").connectedAccountId).toBe("ca_new") + }) + + test("desktop OAuth retains the native completion URL and denied web OAuth stays in Nova", async () => { + await post("connect", { slug: "linear" }) + let callback = new URL(callbackUrl) + callback.searchParams.set("error", "denied") + const desktop = await fetch(`${baseUrl}${callback.pathname}${callback.search}`, { redirect: "manual" }) + expect(desktop.headers.get("location")).toBe("supercode://composio/connected?provider=linear&error=oauth_denied") + await post("connect", { slug: "linear", returnTo: "nova" }) + callback = new URL(callbackUrl) + callback.searchParams.set("status", "failed") + const web = await fetch(`${baseUrl}${callback.pathname}${callback.search}`, { redirect: "manual" }) + expect(web.headers.get("location")).toBe("http://nova.localhost:3003/connections?integration_error=oauth_denied") + }) + + test("rejects tampered OAuth state and another user’s callback account", async () => { + await post("connect", { slug: "linear", returnTo: "nova" }) + const callback = new URL(callbackUrl) + const original = callback.searchParams.get("state") ?? "" + callback.searchParams.set("state", `${original}changed`) + expect((await fetch(`${baseUrl}${callback.pathname}${callback.search}`)).status).toBe(400) + callback.searchParams.set("state", original) + callback.searchParams.set("connected_account_id", "ca_other") + const foreign = await fetch(`${baseUrl}${callback.pathname}${callback.search}`, { redirect: "manual" }) + expect(foreign.headers.get("location")).toContain("integration_error=connection_invalid") + }) + + test("disconnect cannot touch Review or another user, and revokes the shared CLI account", async () => { + expect((await post("disconnect", { connectedAccountId: "ca_review" })).status).toBe(404) + expect((await post("disconnect", { connectedAccountId: "ca_other" })).status).toBe(404) + expect(client.connectedAccounts.delete).not.toHaveBeenCalled() + expect((await post("disconnect", { connectedAccountId: "ca_calendar" })).status).toBe(200) + expect((await service.listApps("user_1")).find((app) => app.slug === "googlecalendar")?.connected).toBe(false) + }) + + test("exposes and executes tools only for connected CLI accounts", async () => { + const tools = await (await post("tools")).json() + expect(tools.tools.map((tool: { name: string }) => tool.name)).toEqual(["GOOGLECALENDAR_LIST_EVENTS", "GOOGLECALENDAR_CREATE_EVENT"]) + expect(tools.tools[0].requiresApproval).toBe(false) + expect(tools.tools[1].requiresApproval).toBe(true) + expect((await post("execute", { name: "GITHUB_CREATE_ISSUE", arguments: {} })).status).toBe(403) + expect((await post("execute", { name: "GOOGLECALENDAR_LIST_EVENTS", arguments: {} })).status).toBe(200) + expect(client.tools.execute).toHaveBeenCalledWith("GOOGLECALENDAR_LIST_EVENTS", { + userId: "user_1", connectedAccountId: "ca_calendar", arguments: {}, version: "latest", + }) + }) + + test("requires authentication and validates input", async () => { + expect((await post("apps", {}, "invalid")).status).toBe(401) + expect((await post("connect", { slug: "../dashboard" })).status).toBe(400) + }) + + test("paginates user-scoped accounts and auth configs", async () => { + client.connectedAccounts.list.mockImplementation(async (query) => ({ + items: query.cursor ? [accounts[0]!] : [], nextCursor: query.cursor ? null : "page_2", + })) + client.authConfigs.list.mockImplementation(async (query) => ({ + items: query.cursor ? [{ id: "ac_calendar", toolkit: { slug: "googlecalendar" }, status: "ENABLED" }] : [], nextCursor: query.cursor ? null : "page_2", + })) + const apps = await service.listApps("user_1") + expect(apps).toHaveLength(1) + expect(apps[0]?.connectedAccountId).toBe("ca_calendar") + expect(client.connectedAccounts.list.mock.calls[1]?.[0].userIds).toEqual(["user_1"]) + }) +}) diff --git a/apps/supercode-cli/server/src/routes/composio.ts b/apps/supercode-cli/server/src/routes/composio.ts index a1432886..b3089bea 100644 --- a/apps/supercode-cli/server/src/routes/composio.ts +++ b/apps/supercode-cli/server/src/routes/composio.ts @@ -1,95 +1,142 @@ -import type { Express, Request } from "express" +import { createHmac, timingSafeEqual } from "node:crypto" +import type { Express, Request, Response } from "express" +import { z } from "zod" -type AuthenticatedUser = { id: string } -type Authenticate = (req: Request) => Promise +import { ServerComposioService } from "../lib/composio" -async function requireUser(req: Request, authenticate: Authenticate) { - const user = await authenticate(req) - if (!user) throw Object.assign(new Error("Unauthorized"), { statusCode: 401 }) - return user -} +type Authenticate = (req: Request) => Promise<{ id: string } | null> + +const slugSchema = z.string().regex(/^[a-z0-9_-]{1,64}$/) +const connectSchema = z.object({ slug: slugSchema, returnTo: z.enum(["desktop", "nova"]).default("desktop") }) +const disconnectSchema = z.object({ connectedAccountId: z.string().min(1).max(256) }) +const toolNameSchema = z.string().min(1).max(256) +const executeSchema = z.object({ + toolName: toolNameSchema.optional(), + name: toolNameSchema.optional(), + arguments: z.record(z.unknown()).default({}), +}).refine((input) => Boolean(input.toolName || input.name) && (!input.toolName || !input.name || input.toolName === input.name)) + .transform((input) => ({ name: input.toolName ?? input.name ?? "", arguments: input.arguments })) +const stateSchema = connectSchema.extend({ userId: z.string().min(1), expiresAt: z.number() }) -function sendError(res: any, error: unknown, fallback: string) { - const value = error as { message?: string; statusCode?: number } - res.status(value.statusCode ?? 500).json({ error: value.message || fallback }) +function stateSecret(): string { + const secret = process.env.BETTER_AUTH_SECRET + if (!secret) throw Object.assign(new Error("BETTER_AUTH_SECRET is required on the CLI server"), { statusCode: 503 }) + return secret } -async function proxyDesktopComposio(req: Request, res: any, authenticate: Authenticate) { - const user = await requireUser(req, authenticate) - if (!user) return - const dashboardUrl = ( - process.env.SUPERCODE_DASHBOARD_API_URL || - process.env.SUPERCODE_APP_URL || - (process.env.NODE_ENV === "production" ? "https://supercodeai.vercel.app" : "http://localhost:3001") - ).replace(/\/$/, "") - const suffix = req.originalUrl.replace(/^\/api\/composio/, "") - const response = await fetch(`${dashboardUrl}/api/desktop/composio${suffix}`, { - method: req.method, - headers: { - Authorization: req.headers.authorization!, - Accept: "application/json", - "Content-Type": "application/json", - }, - body: JSON.stringify(req.body ?? {}), - signal: AbortSignal.timeout(120_000), - }) - const contentType = response.headers.get("content-type") || "" - const body = await response.text() - if (!contentType.toLowerCase().includes("application/json")) { - res.status(response.ok ? 502 : response.status).json({ - error: "The Composio control plane returned an invalid response", - }) - return +function verifyState(state: unknown): z.infer | null { + if (typeof state !== "string") return null + const [payload, signature, extra] = state.split(".") + if (!payload || !signature || extra) return null + const expected = createHmac("sha256", stateSecret()).update(payload).digest() + const received = Buffer.from(signature, "base64url") + if (received.length !== expected.length || !timingSafeEqual(received, expected)) return null + try { + const result = stateSchema.safeParse(JSON.parse(Buffer.from(payload, "base64url").toString("utf8"))) + return result.success && result.data.expiresAt > Date.now() ? result.data : null + } catch { + return null } - res.status(response.status).type("application/json").send(body) } -export function registerComposioRoutes(app: Express, authenticate: Authenticate): void { - app.post("/api/composio/session", async (req, res) => { - try { - await proxyDesktopComposio(req, res, authenticate) - } catch (error) { - sendError(res, error, "Composio session creation failed") - } - }) - - app.post("/api/composio/apps", async (req, res) => { - try { - await proxyDesktopComposio(req, res, authenticate) - } catch (error) { - sendError(res, error, "Composio list apps failed") - } - }) - - app.post("/api/composio/connect", async (req, res) => { - try { - await proxyDesktopComposio(req, res, authenticate) - } catch (error) { - sendError(res, error, "Composio connection failed") - } - }) +function completionUrl(returnTo: "desktop" | "nova", slug: string, error?: string): URL { + const novaUrl = process.env.NOVA_WEB_URL + || (process.env.NODE_ENV === "production" ? "https://nova.supercodeai.tech" : "http://nova.localhost:3003") + const url = returnTo === "nova" ? new URL("/connections", novaUrl) : new URL("supercode://composio/connected") + url.searchParams.set(returnTo === "nova" ? "connected" : "provider", slug) + if (error) { + url.searchParams.delete("connected") + url.searchParams.set(returnTo === "nova" ? "integration_error" : "error", error) + } + return url +} - app.post("/api/composio/disconnect", async (req, res) => { - try { - await proxyDesktopComposio(req, res, authenticate) - } catch (error) { - sendError(res, error, "Composio disconnect failed") - } +function sendError(res: Response, error: unknown): void { + if (error instanceof z.ZodError) { + res.status(400).json({ error: "Invalid Composio request" }) + return + } + const value = error as { statusCode?: number } + const status = value?.statusCode ?? 502 + res.status(status).json({ + error: status === 502 ? "The CLI server could not complete the Composio request" : error instanceof Error ? error.message : "Composio request failed", }) +} - app.post("/api/composio/tools", async (req, res) => { - try { - await proxyDesktopComposio(req, res, authenticate) - } catch (error) { - sendError(res, error, "Composio tools failed") - } - }) +export function registerComposioRoutes( + app: Express, + authenticate: Authenticate, + service = new ServerComposioService(), +): void { + for (const action of ["session", "apps", "connect", "disconnect", "tools", "execute"] as const) { + app.post(`/api/composio/${action}`, async (req, res) => { + res.setHeader("Cache-Control", "no-store") + try { + const user = await authenticate(req) + if (!user) { + res.status(401).json({ error: "Unauthorized" }) + return + } + switch (action) { + case "session": + res.json(await service.createSession(user.id)) + return + case "apps": + res.json({ apps: await service.listApps(user.id) }) + return + case "tools": + res.json({ tools: await service.listTools(user.id) }) + return + case "connect": { + const input = connectSchema.parse(req.body) + const payload = Buffer.from(JSON.stringify({ ...input, userId: user.id, expiresAt: Date.now() + 10 * 60 * 1000 })).toString("base64url") + const signature = createHmac("sha256", stateSecret()).update(payload).digest("base64url") + const serverUrl = process.env.BETTER_AUTH_URL || process.env.SUPERCODE_SERVER_URL + if (!serverUrl) throw Object.assign(new Error("The CLI server public URL is not configured"), { statusCode: 503 }) + const callback = new URL("/api/composio/callback", serverUrl) + callback.searchParams.set("state", `${payload}.${signature}`) + res.json(await service.connect(user.id, input.slug, callback.toString())) + return + } + case "disconnect": { + const input = disconnectSchema.parse(req.body) + await service.disconnect(user.id, input.connectedAccountId) + res.json({ success: true }) + return + } + case "execute": { + const input = executeSchema.parse(req.body) + res.json(await service.executeTool(user.id, input.name, input.arguments)) + } + } + } catch (error) { + sendError(res, error) + } + }) + } - app.post("/api/composio/execute", async (req, res) => { + app.get("/api/composio/callback", async (req, res) => { + res.setHeader("Cache-Control", "no-store") try { - await proxyDesktopComposio(req, res, authenticate) + const state = verifyState(req.query.state) + if (!state) { + res.status(400).json({ error: "Invalid or expired Composio OAuth state" }) + return + } + const status = req.query.status ?? req.query.connection_status + if (req.query.error || (status && status !== "success" && status !== "ACTIVE")) { + res.redirect(303, completionUrl(state.returnTo, state.slug, "oauth_denied").toString()) + return + } + const accountId = req.query.connected_account_id ?? req.query.connectedAccountId + try { + const valid = typeof accountId === "string" && await service.verifyConnection(state.userId, state.slug, accountId) + res.redirect(303, completionUrl(state.returnTo, state.slug, valid ? undefined : "connection_invalid").toString()) + } catch { + res.redirect(303, completionUrl(state.returnTo, state.slug, "connect_failed").toString()) + } } catch (error) { - sendError(res, error, "Composio tool execution failed") + sendError(res, error) } }) } diff --git a/apps/supercode-cli/server/src/runtime/stream/openai-compatible-stream.ts b/apps/supercode-cli/server/src/runtime/stream/openai-compatible-stream.ts index 1c1539d2..af45859e 100644 --- a/apps/supercode-cli/server/src/runtime/stream/openai-compatible-stream.ts +++ b/apps/supercode-cli/server/src/runtime/stream/openai-compatible-stream.ts @@ -101,8 +101,38 @@ export function extractDeltaReasoning(delta: any): string { return chunks.join("") } -/** Flatten AI-SDK / OpenAI message content (string or parts array) for upstream APIs. */ -export function serializeChatContent(content: unknown): string { +/** + * Serialize message content for OpenAI-compatible APIs. + * Multimodal parts (text + image_url / image) are preserved as an array so + * vision models can see pasted/dropped photos from Nova web. + */ +export function serializeChatContent(content: unknown): string | Array> { + if (Array.isArray(content)) { + const parts: Array> = [] + let hasMedia = false + for (const part of content) { + if (!part || typeof part !== "object") continue + const obj = part as Record + const type = typeof obj.type === "string" ? obj.type : "" + if (type === "text" && typeof obj.text === "string") { + parts.push({ type: "text", text: obj.text }) + continue + } + if (type === "image_url") { + hasMedia = true + parts.push({ type: "image_url", image_url: obj.image_url }) + continue + } + if (type === "image" && typeof obj.image === "string") { + hasMedia = true + parts.push({ type: "image_url", image_url: { url: obj.image } }) + continue + } + const text = joinTextParts(part) + if (text) parts.push({ type: "text", text }) + } + if (hasMedia && parts.length > 0) return parts + } return joinTextParts(content) } diff --git a/apps/web/.env.example b/apps/web/.env.example index dcd95169..916d4b61 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -1,5 +1,7 @@ # ─── Database ─── DATABASE_URL= +# Terminal / harness auth DB (CLI server sessions). Falls back to DATABASE_URL. +# DATABASE_URL_TERMINAL= # ─── Auth (Better-Auth) ─── BETTER_AUTH_URL= @@ -9,8 +11,12 @@ GITHUB_CLIENT_ID= GITHUB_CLIENT_SECRET= # ─── Terminal Client ─── +# The CLI web client used for Nova authentication. +# Local: http://localhost:3000 +# Production: https://supercode-terminal.vercel.app +SUPERCODE_CLI_CLIENT_URL= # Where the terminal server lives (http://localhost:3004 for local dev, -# https://supercode-terminal.vercel.app in production). +# https://supercode-8w7e.onrender.com in production). NEXT_PUBLIC_TERMINAL_URL= # Server-only terminal URL used to verify Nova bearer tokens for desktop Review. # TERMINAL_SERVER_URL remains supported for existing deployments. diff --git a/apps/web/app/(auth)/login/page.tsx b/apps/web/app/(auth)/login/page.tsx index 338f02af..2f3e6828 100644 --- a/apps/web/app/(auth)/login/page.tsx +++ b/apps/web/app/(auth)/login/page.tsx @@ -1,16 +1,38 @@ -import React from 'react' -import LoginUI from '@/modules/components/login-ui' -import { requireUnAuth } from '@/modules/components/utils/auth-utils' +import { headers } from "next/headers" +import { redirect } from "next/navigation" -export const dynamic = 'force-dynamic' +import LoginUI from "@/modules/components/login-ui" +import { requireUnAuth } from "@/modules/components/utils/auth-utils" -const LoginPage = async() => { +export const dynamic = "force-dynamic" +const NOVA_HOSTS = new Set(["nova.supercodeai.tech", "nova.localhost"]) - await requireUnAuth() - return ( - - ) +const LoginPage = async () => { + const requestHeaders = await headers() + const forwardedHost = requestHeaders.get("x-forwarded-host")?.split(",")[0] + const host = (forwardedHost ?? requestHeaders.get("host") ?? "").trim() + const hostname = host.split(":")[0].toLowerCase() + + const isNovaHost = NOVA_HOSTS.has(hostname) + await requireUnAuth(isNovaHost ? "/app" : "/") + + if (isNovaHost) { + const protocol = requestHeaders.get("x-forwarded-proto")?.split(",")[0] + ?? (hostname === "nova.localhost" ? "http" : "https") + const novaUrl = hostname === "nova.supercodeai.tech" + ? "https://nova.supercodeai.tech/app" + : `${protocol}://${host}/app` + const cliClientUrl = process.env.SUPERCODE_CLI_CLIENT_URL + ?? (hostname === "nova.localhost" + ? "http://localhost:3000" + : "https://supercode-terminal.vercel.app") + const signInUrl = new URL("/sign-in", cliClientUrl) + signInUrl.searchParams.set("redirect", novaUrl) + redirect(signInUrl.toString()) + } + + return } export default LoginPage diff --git a/apps/web/app/(nova-app)/layout.tsx b/apps/web/app/(nova-app)/layout.tsx new file mode 100644 index 00000000..9da234c6 --- /dev/null +++ b/apps/web/app/(nova-app)/layout.tsx @@ -0,0 +1,11 @@ +import type { Metadata } from "next" +import type { ReactNode } from "react" + +export const metadata: Metadata = { + title: "Nova | Supercode", + description: "Work with Nova, your Supercode engineering agent.", +} + +export default function NovaAppLayout({ children }: { children: ReactNode }) { + return children +} diff --git a/apps/web/app/(nova-app)/nova-app-internal/approvals/page.tsx b/apps/web/app/(nova-app)/nova-app-internal/approvals/page.tsx new file mode 100644 index 00000000..c91adbc3 --- /dev/null +++ b/apps/web/app/(nova-app)/nova-app-internal/approvals/page.tsx @@ -0,0 +1,23 @@ +import { requireAuth } from "@/modules/components/utils/auth-utils" +import { NovaApp } from "@/modules/nova-web/nova-app" +import { listAgentSessions } from "@/modules/nova/sessions/service" + +export const dynamic = "force-dynamic" + +export default async function NovaApprovalsPage() { + const session = await requireAuth() + const sessions = await listAgentSessions(session.user.id) + + return ( + + ) +} diff --git a/apps/web/app/(nova-app)/nova-app-internal/connections/page.tsx b/apps/web/app/(nova-app)/nova-app-internal/connections/page.tsx new file mode 100644 index 00000000..564ec8d9 --- /dev/null +++ b/apps/web/app/(nova-app)/nova-app-internal/connections/page.tsx @@ -0,0 +1,23 @@ +import { requireAuth } from "@/modules/components/utils/auth-utils" +import { NovaApp } from "@/modules/nova-web/nova-app" +import { listAgentSessions } from "@/modules/nova/sessions/service" + +export const dynamic = "force-dynamic" + +export default async function NovaConnectionsPage() { + const session = await requireAuth() + const sessions = await listAgentSessions(session.user.id) + + return ( + + ) +} diff --git a/apps/web/app/(nova-app)/nova-app-internal/more/page.tsx b/apps/web/app/(nova-app)/nova-app-internal/more/page.tsx new file mode 100644 index 00000000..75d53237 --- /dev/null +++ b/apps/web/app/(nova-app)/nova-app-internal/more/page.tsx @@ -0,0 +1,23 @@ +import { requireAuth } from "@/modules/components/utils/auth-utils" +import { NovaApp } from "@/modules/nova-web/nova-app" +import { listAgentSessions } from "@/modules/nova/sessions/service" + +export const dynamic = "force-dynamic" + +export default async function NovaMorePage() { + const session = await requireAuth() + const sessions = await listAgentSessions(session.user.id) + + return ( + + ) +} diff --git a/apps/web/app/(nova-app)/nova-app-internal/page.tsx b/apps/web/app/(nova-app)/nova-app-internal/page.tsx new file mode 100644 index 00000000..fec88f0f --- /dev/null +++ b/apps/web/app/(nova-app)/nova-app-internal/page.tsx @@ -0,0 +1,23 @@ +import { requireAuth } from "@/modules/components/utils/auth-utils" +import { NovaApp } from "@/modules/nova-web/nova-app" +import { listAgentSessions } from "@/modules/nova/sessions/service" + +export const dynamic = "force-dynamic" + +export default async function NovaHomePage() { + const session = await requireAuth() + const sessions = await listAgentSessions(session.user.id) + + return ( + + ) +} diff --git a/apps/web/app/(nova-app)/nova-app-internal/pulls/[id]/page.tsx b/apps/web/app/(nova-app)/nova-app-internal/pulls/[id]/page.tsx new file mode 100644 index 00000000..39f1c809 --- /dev/null +++ b/apps/web/app/(nova-app)/nova-app-internal/pulls/[id]/page.tsx @@ -0,0 +1,29 @@ +import { requireAuth } from "@/modules/components/utils/auth-utils" +import { NovaApp } from "@/modules/nova-web/nova-app" +import { listAgentSessions } from "@/modules/nova/sessions/service" + +export const dynamic = "force-dynamic" + +export default async function NovaPullDetailPage({ + params, +}: { + params: Promise<{ id: string }> +}) { + const session = await requireAuth() + const { id } = await params + const sessions = await listAgentSessions(session.user.id) + + return ( + + ) +} diff --git a/apps/web/app/(nova-app)/nova-app-internal/pulls/page.tsx b/apps/web/app/(nova-app)/nova-app-internal/pulls/page.tsx new file mode 100644 index 00000000..f2df667f --- /dev/null +++ b/apps/web/app/(nova-app)/nova-app-internal/pulls/page.tsx @@ -0,0 +1,23 @@ +import { requireAuth } from "@/modules/components/utils/auth-utils" +import { NovaApp } from "@/modules/nova-web/nova-app" +import { listAgentSessions } from "@/modules/nova/sessions/service" + +export const dynamic = "force-dynamic" + +export default async function NovaPullsPage() { + const session = await requireAuth() + const sessions = await listAgentSessions(session.user.id) + + return ( + + ) +} diff --git a/apps/web/app/(nova-app)/nova-app-internal/s/[sessionId]/page.tsx b/apps/web/app/(nova-app)/nova-app-internal/s/[sessionId]/page.tsx new file mode 100644 index 00000000..c75257e7 --- /dev/null +++ b/apps/web/app/(nova-app)/nova-app-internal/s/[sessionId]/page.tsx @@ -0,0 +1,29 @@ +import { requireAuth } from "@/modules/components/utils/auth-utils" +import { NovaApp } from "@/modules/nova-web/nova-app" +import { listAgentSessions } from "@/modules/nova/sessions/service" + +export const dynamic = "force-dynamic" + +export default async function NovaSessionPage({ + params, +}: { + params: Promise<{ sessionId: string }> +}) { + const session = await requireAuth() + const { sessionId } = await params + const sessions = await listAgentSessions(session.user.id) + + return ( + + ) +} diff --git a/apps/web/app/(nova-app)/nova-app-internal/settings/page.tsx b/apps/web/app/(nova-app)/nova-app-internal/settings/page.tsx new file mode 100644 index 00000000..cf5da09d --- /dev/null +++ b/apps/web/app/(nova-app)/nova-app-internal/settings/page.tsx @@ -0,0 +1,36 @@ +import { redirect } from "next/navigation" + +import { requireAuth } from "@/modules/components/utils/auth-utils" +import { normalizeSettingsSection } from "@/modules/nova-web/settings-sections" +import { NovaApp } from "@/modules/nova-web/nova-app" +import { listAgentSessions } from "@/modules/nova/sessions/service" + +export const dynamic = "force-dynamic" + +export default async function NovaSettingsPage({ + searchParams, +}: { + searchParams: Promise<{ section?: string }> +}) { + const session = await requireAuth() + const params = await searchParams + const section = params.section ?? null + if (section && normalizeSettingsSection(section) !== section) { + redirect("/settings") + } + const sessions = await listAgentSessions(session.user.id) + + return ( + + ) +} diff --git a/apps/web/app/(pages)/nova/page.tsx b/apps/web/app/(pages)/nova/page.tsx index 82ac0c9b..f27efbce 100644 --- a/apps/web/app/(pages)/nova/page.tsx +++ b/apps/web/app/(pages)/nova/page.tsx @@ -1,159 +1,5 @@ -import type { Metadata } from "next" -import { Bot, Check, CodeXml, MessagesSquare, Mic2 } from "lucide-react" +import { redirect } from "next/navigation" -import { ClipboardListIcon } from "@/components/animate-ui/icons/clipboard-list" -import Footer from "@/components/homepage/footer" -import Navbar from "@/components/homepage/navbar" -import { NovaInviteForm } from "./nova-invite-form" - -export const metadata: Metadata = { - title: "Nova — The AI Engineer | Supercode", - description: - "Meet Nova by Supercode: an AI engineer that owns tasks, works through an agent harness, reviews code, and collaborates with your team by voice.", - openGraph: { - title: "Nova — The AI Engineer", - description: - "An accountable AI engineering teammate for implementation, code review, communication, and voice collaboration.", - url: "/nova", - }, -} - -const capabilities = [ - { - icon: Bot, - label: "Agent harness", - detail: "Plans, executes, tests, and reports instead of stopping at suggestions.", - }, - { - icon: CodeXml, - label: "Code review", - detail: "Understands the codebase, surfaces risk, and helps move pull requests forward.", - }, - { - icon: Mic2, - label: "Voice native", - detail: "Discuss work, make decisions, and steer tasks as naturally as a teammate.", - }, - { - icon: MessagesSquare, - label: "Team connected", - detail: "Built to work where engineering teams already coordinate and communicate.", - }, -] - -export default function NovaPage() { - return ( -
-
- ) +export default function NovaRedirectPage() { + redirect("https://nova.supercodeai.tech/") } diff --git a/apps/web/app/api/auth/cli/callback/route.ts b/apps/web/app/api/auth/cli/callback/route.ts new file mode 100644 index 00000000..e75e746a --- /dev/null +++ b/apps/web/app/api/auth/cli/callback/route.ts @@ -0,0 +1,129 @@ +import prisma from "@super/db" +import { createSessionCookieForUser } from "@super/auth/server" +import { NextResponse } from "next/server" +import { z } from "zod" + +import { harnessTokenCookieHeader } from "@/modules/nova/harness/cookies" + +const callbackQuerySchema = z.object({ + token: z.string().min(1).max(512), + redirect: z.literal("/app").default("/app"), +}) + +const cliSessionSchema = z.object({ + session: z.object({ + token: z.string().min(1), + expiresAt: z.union([z.string(), z.date()]).optional(), + }).optional(), + user: z.object({ + name: z.string().min(1), + email: z.string().email(), + emailVerified: z.boolean(), + image: z.string().nullable().optional(), + }), +}) + +function getTerminalServerUrl(): string { + return process.env.SUPERCODE_TERMINAL_API_URL + ?? process.env.TERMINAL_SERVER_URL + ?? (process.env.NODE_ENV === "production" + ? "https://supercode-8w7e.onrender.com" + : "http://localhost:3004") +} + +function errorResponse(message: string, status: number): NextResponse { + return NextResponse.json( + { error: message }, + { + status, + headers: { "Cache-Control": "no-store" }, + }, + ) +} + +export async function GET(request: Request): Promise { + const url = new URL(request.url) + const query = callbackQuerySchema.safeParse({ + token: url.searchParams.get("token"), + redirect: url.searchParams.get("redirect") ?? undefined, + }) + + if (!query.success) { + return errorResponse("Invalid or missing login token", 400) + } + + let cliResponse: Response + try { + cliResponse = await fetch( + new URL("/api/auth/one-time-token/verify", getTerminalServerUrl()), + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token: query.data.token }), + cache: "no-store", + }, + ) + } catch { + return errorResponse("The authentication service is unavailable", 502) + } + + if (!cliResponse.ok) { + return errorResponse("The login token is invalid, expired, or already used", 401) + } + + const cliSession = cliSessionSchema.safeParse(await cliResponse.json()) + if (!cliSession.success) { + return errorResponse("The authentication service returned an invalid session", 502) + } + + const cliUser = cliSession.data.user + const email = cliUser.email.trim().toLowerCase() + const existingUser = await prisma.user.findFirst({ + where: { + email: { + equals: email, + mode: "insensitive", + }, + }, + select: { id: true }, + }) + const user = existingUser + ? await prisma.user.update({ + where: { id: existingUser.id }, + data: { + name: cliUser.name, + email, + emailVerified: cliUser.emailVerified, + image: cliUser.image ?? null, + }, + select: { id: true }, + }) + : await prisma.user.create({ + data: { + id: crypto.randomUUID(), + name: cliUser.name, + email, + emailVerified: cliUser.emailVerified, + image: cliUser.image ?? null, + }, + select: { id: true }, + }) + + const forwardedFor = request.headers.get("x-forwarded-for") + const sessionCookie = await createSessionCookieForUser(user.id, { + ipAddress: forwardedFor?.split(",")[0]?.trim(), + userAgent: request.headers.get("user-agent") ?? undefined, + }) + const response = NextResponse.redirect(new URL(query.data.redirect, request.url)) + response.headers.append("Set-Cookie", sessionCookie) + + // Keep the CLI/harness session token so Nova web can call /api/ai/chat + // the same way Supercode Desktop does (Bearer auth on the terminal server). + const harnessToken = cliSession.data.session?.token + if (harnessToken) { + response.headers.append("Set-Cookie", harnessTokenCookieHeader(harnessToken)) + } + + response.headers.set("Cache-Control", "no-store") + return response +} diff --git a/apps/web/app/api/desktop/nova/sessions/[sessionId]/messages/route.ts b/apps/web/app/api/desktop/nova/sessions/[sessionId]/messages/route.ts new file mode 100644 index 00000000..b91398e1 --- /dev/null +++ b/apps/web/app/api/desktop/nova/sessions/[sessionId]/messages/route.ts @@ -0,0 +1,42 @@ +import { NextRequest, NextResponse } from "next/server" +import { z } from "zod" + +import { getDesktopReviewUser } from "@/lib/desktop-auth" +import { postSessionMessage } from "@/modules/nova/sessions/service" + +const postMessageSchema = z.object({ + content: z.string().trim().min(1).max(20_000), + clientMessageId: z.string().trim().min(1).max(128).optional(), +}) + +export async function POST( + request: NextRequest, + context: { params: Promise<{ sessionId: string }> }, +) { + try { + const user = await getDesktopReviewUser(request.headers.get("authorization")) + if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + + const parsed = postMessageSchema.safeParse(await request.json()) + if (!parsed.success) { + return NextResponse.json({ error: "Invalid message", issues: parsed.error.issues }, { status: 400 }) + } + + const { sessionId } = await context.params + const posted = await postSessionMessage({ + userId: user.id, + sessionId, + content: parsed.data.content, + clientMessageId: parsed.data.clientMessageId, + surface: "desktop", + }) + if (!posted) return NextResponse.json({ error: "Session not found" }, { status: 404 }) + return NextResponse.json(posted, { status: 201 }) + } catch (error) { + console.error("[api/desktop/nova/sessions/:sessionId/messages]", error) + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed to post message" }, + { status: 500 }, + ) + } +} diff --git a/apps/web/app/api/desktop/nova/sessions/route.ts b/apps/web/app/api/desktop/nova/sessions/route.ts index e1922632..d1440f98 100644 --- a/apps/web/app/api/desktop/nova/sessions/route.ts +++ b/apps/web/app/api/desktop/nova/sessions/route.ts @@ -28,7 +28,12 @@ export async function POST(request: NextRequest) { if (!parsed.success) { return NextResponse.json({ error: "Invalid session", issues: parsed.error.issues }, { status: 400 }) } - const session = await createAgentSession({ userId: user.id, ...parsed.data }) + const session = await createAgentSession({ + userId: user.id, + objective: parsed.data.objective, + mode: parsed.data.mode, + surface: "desktop", + }) return NextResponse.json({ session }, { status: 201 }) } catch (error) { console.error("[api/desktop/nova/sessions]", error) diff --git a/apps/web/app/api/integrations/github/callback/route.ts b/apps/web/app/api/integrations/github/callback/route.ts index 2f0503f7..744eb2c0 100644 --- a/apps/web/app/api/integrations/github/callback/route.ts +++ b/apps/web/app/api/integrations/github/callback/route.ts @@ -1,8 +1,12 @@ import { handleComposioCallback } from "@/modules/integrations/lib/callback-flow" +export const runtime = "nodejs" + export async function GET(request: Request) { + const url = new URL(request.url) return handleComposioCallback({ provider: "github", - searchParams: new URL(request.url).searchParams, + searchParams: url.searchParams, + requestOrigin: url.origin, }) } diff --git a/apps/web/app/api/integrations/linear/callback/route.ts b/apps/web/app/api/integrations/linear/callback/route.ts index d7c71054..4e26e1cb 100644 --- a/apps/web/app/api/integrations/linear/callback/route.ts +++ b/apps/web/app/api/integrations/linear/callback/route.ts @@ -7,5 +7,6 @@ export async function GET(request: NextRequest) { return handleComposioCallback({ provider: "linear", searchParams: request.nextUrl.searchParams, + requestOrigin: request.nextUrl.origin, }) } diff --git a/apps/web/app/api/integrations/slack/callback/route.ts b/apps/web/app/api/integrations/slack/callback/route.ts index 83f99142..8612bd92 100644 --- a/apps/web/app/api/integrations/slack/callback/route.ts +++ b/apps/web/app/api/integrations/slack/callback/route.ts @@ -7,5 +7,6 @@ export async function GET(request: NextRequest) { return handleComposioCallback({ provider: "slack", searchParams: request.nextUrl.searchParams, + requestOrigin: request.nextUrl.origin, }) } diff --git a/apps/web/app/api/nova/approvals/[approvalId]/route.ts b/apps/web/app/api/nova/approvals/[approvalId]/route.ts new file mode 100644 index 00000000..f7120030 --- /dev/null +++ b/apps/web/app/api/nova/approvals/[approvalId]/route.ts @@ -0,0 +1,53 @@ +import { auth } from "@super/auth/server" +import { approvalDecisionInputSchema } from "@super/nova" +import { headers } from "next/headers" +import { NextRequest, NextResponse } from "next/server" + +import { + ApprovalServiceError, + decideApproval, +} from "@/modules/nova/approvals/service" + +function errorStatus(error: ApprovalServiceError): number { + switch (error.code) { + case "not_found": return 404 + case "forbidden": return 403 + case "conflict": + case "expired": return 409 + } +} + +export async function POST( + request: NextRequest, + context: { params: Promise<{ approvalId: string }> }, +) { + try { + const session = await auth.api.getSession({ headers: await headers() }) + if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + + const parsed = approvalDecisionInputSchema.safeParse(await request.json()) + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid approval decision", issues: parsed.error.issues }, + { status: 400 }, + ) + } + + const { approvalId } = await context.params + const approval = await decideApproval({ + userId: session.user.id, + approvalId, + decision: parsed.data, + }) + return NextResponse.json({ approval }) + } catch (error) { + if (error instanceof ApprovalServiceError) { + return NextResponse.json( + { error: error.message, code: error.code }, + { status: errorStatus(error) }, + ) + } + console.error("[api/nova/approvals/:approvalId]", error) + return NextResponse.json({ error: "Failed to decide Nova approval" }, { status: 500 }) + } +} diff --git a/apps/web/app/api/nova/approvals/route.ts b/apps/web/app/api/nova/approvals/route.ts new file mode 100644 index 00000000..cbe9a6c7 --- /dev/null +++ b/apps/web/app/api/nova/approvals/route.ts @@ -0,0 +1,16 @@ +import { auth } from "@super/auth/server" +import { headers } from "next/headers" +import { NextResponse } from "next/server" + +import { listApprovals } from "@/modules/nova/approvals/service" + +export async function GET() { + try { + const session = await auth.api.getSession({ headers: await headers() }) + if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + return NextResponse.json(await listApprovals(session.user.id)) + } catch (error) { + console.error("[api/nova/approvals]", error) + return NextResponse.json({ error: "Failed to load Nova approvals" }, { status: 500 }) + } +} diff --git a/apps/web/app/api/nova/composio/apps/route.ts b/apps/web/app/api/nova/composio/apps/route.ts new file mode 100644 index 00000000..5a1d810a --- /dev/null +++ b/apps/web/app/api/nova/composio/apps/route.ts @@ -0,0 +1,14 @@ +import { requestHarnessComposio, withHarnessComposio } from "@/modules/nova/harness/composio" + +export const runtime = "nodejs" +export const dynamic = "force-dynamic" + +export async function GET(request: Request) { + return withHarnessComposio(request, async (token) => { + const [payload, mcp] = await Promise.all([ + requestHarnessComposio<{ apps: unknown[] }>("apps", token), + requestHarnessComposio<{ url: string; sessionId: string; headers: Record }>("session", token), + ]) + return { apps: payload.apps, mcp: { url: mcp.url, sessionId: mcp.sessionId, hasHeaders: Object.keys(mcp.headers).length > 0 } } + }) +} diff --git a/apps/web/app/api/nova/composio/connect/route.ts b/apps/web/app/api/nova/composio/connect/route.ts new file mode 100644 index 00000000..4307c33e --- /dev/null +++ b/apps/web/app/api/nova/composio/connect/route.ts @@ -0,0 +1,19 @@ +import { z } from "zod" + +import { requestHarnessComposio, withHarnessComposio } from "@/modules/nova/harness/composio" + +const bodySchema = z.object({ + slug: z.string().trim().min(1).max(64), +}) + +export const runtime = "nodejs" + +export async function POST(request: Request) { + return withHarnessComposio(request, async (token) => { + const parsed = bodySchema.safeParse(await request.json().catch(() => null)) + if (!parsed.success) { + throw Object.assign(new Error("A toolkit slug is required"), { statusCode: 400 }) + } + return requestHarnessComposio("connect", token, { ...parsed.data, returnTo: "nova" }) + }) +} diff --git a/apps/web/app/api/nova/composio/disconnect/route.ts b/apps/web/app/api/nova/composio/disconnect/route.ts new file mode 100644 index 00000000..395ef3a1 --- /dev/null +++ b/apps/web/app/api/nova/composio/disconnect/route.ts @@ -0,0 +1,19 @@ +import { z } from "zod" + +import { requestHarnessComposio, withHarnessComposio } from "@/modules/nova/harness/composio" + +const bodySchema = z.object({ + connectedAccountId: z.string().trim().min(1).max(256), +}) + +export const runtime = "nodejs" + +export async function POST(request: Request) { + return withHarnessComposio(request, async (token) => { + const parsed = bodySchema.safeParse(await request.json().catch(() => null)) + if (!parsed.success) { + throw Object.assign(new Error("A connected account ID is required"), { statusCode: 400 }) + } + return requestHarnessComposio("disconnect", token, parsed.data) + }) +} diff --git a/apps/web/app/api/nova/composio/tools/route.ts b/apps/web/app/api/nova/composio/tools/route.ts new file mode 100644 index 00000000..7761a4a4 --- /dev/null +++ b/apps/web/app/api/nova/composio/tools/route.ts @@ -0,0 +1,8 @@ +import { requestHarnessComposio, withHarnessComposio } from "@/modules/nova/harness/composio" + +export const runtime = "nodejs" +export const dynamic = "force-dynamic" + +export async function GET(request: Request) { + return withHarnessComposio(request, (token) => requestHarnessComposio("tools", token)) +} diff --git a/apps/web/app/api/nova/connectors/github/callback/route.ts b/apps/web/app/api/nova/connectors/github/callback/route.ts index 6ec0a478..7b47ab2d 100644 --- a/apps/web/app/api/nova/connectors/github/callback/route.ts +++ b/apps/web/app/api/nova/connectors/github/callback/route.ts @@ -9,7 +9,7 @@ export const runtime = "nodejs" function completionUrl(returnTo: "desktop" | "web"): string { if (returnTo === "desktop") return "supercode://nova/connectors?provider=github&status=connected" - const base = (process.env.NEXT_PUBLIC_APP_BASE_URL || process.env.BETTER_AUTH_URL || "http://localhost:3000").replace(/\/$/, "") + const base = (process.env.NEXT_PUBLIC_APP_BASE_URL || process.env.BETTER_AUTH_URL || "http://localhost:3003").replace(/\/$/, "") return `${base}/dashboard?novaConnector=github` } diff --git a/apps/web/app/api/nova/connectors/route.ts b/apps/web/app/api/nova/connectors/route.ts new file mode 100644 index 00000000..69f1317c --- /dev/null +++ b/apps/web/app/api/nova/connectors/route.ts @@ -0,0 +1,21 @@ +import { auth } from "@super/auth/server" +import { headers } from "next/headers" +import { NextResponse } from "next/server" + +import { listConnectorStatuses } from "@/modules/nova/connectors/service" + +export async function GET() { + try { + const session = await auth.api.getSession({ headers: await headers() }) + if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + return NextResponse.json({ + connectors: await listConnectorStatuses(session.user.id, "web"), + }) + } catch (error) { + console.error("[api/nova/connectors]", error) + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed to load Nova connectors" }, + { status: 500 }, + ) + } +} diff --git a/apps/web/app/api/nova/local-projects/[projectId]/route.ts b/apps/web/app/api/nova/local-projects/[projectId]/route.ts new file mode 100644 index 00000000..a6c8a8b4 --- /dev/null +++ b/apps/web/app/api/nova/local-projects/[projectId]/route.ts @@ -0,0 +1,62 @@ +import { auth } from "@super/auth/server" +import { headers } from "next/headers" +import { NextRequest, NextResponse } from "next/server" + +import { updateLocalProjectSchema } from "@/modules/nova/local-projects/contracts" +import { getLocalProject, updateLocalProject } from "@/modules/nova/local-projects/service" + +export const runtime = "nodejs" +export const dynamic = "force-dynamic" + +export async function GET( + _request: NextRequest, + context: { params: Promise<{ projectId: string }> }, +) { + const session = await auth.api.getSession({ headers: await headers() }) + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + const { projectId } = await context.params + const project = await getLocalProject(session.user.id, projectId, true) + if (!project) return NextResponse.json({ error: "Not found" }, { status: 404 }) + return NextResponse.json({ project }) +} + +export async function PATCH( + request: NextRequest, + context: { params: Promise<{ projectId: string }> }, +) { + const session = await auth.api.getSession({ headers: await headers() }) + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + const { projectId } = await context.params + const parsed = updateLocalProjectSchema.safeParse(await request.json().catch(() => null)) + if (!parsed.success) { + return NextResponse.json({ error: "Invalid update", issues: parsed.error.issues }, { status: 400 }) + } + try { + const project = await updateLocalProject(session.user.id, projectId, parsed.data) + if (!project) return NextResponse.json({ error: "Not found" }, { status: 404 }) + return NextResponse.json({ project }) + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed to update local project" }, + { status: 400 }, + ) + } +} + +export async function DELETE( + _request: NextRequest, + context: { params: Promise<{ projectId: string }> }, +) { + const session = await auth.api.getSession({ headers: await headers() }) + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + const { projectId } = await context.params + const project = await updateLocalProject(session.user.id, projectId, { status: "archived" }) + if (!project) return NextResponse.json({ error: "Not found" }, { status: 404 }) + return NextResponse.json({ project }) +} diff --git a/apps/web/app/api/nova/local-projects/route.ts b/apps/web/app/api/nova/local-projects/route.ts new file mode 100644 index 00000000..05f5f623 --- /dev/null +++ b/apps/web/app/api/nova/local-projects/route.ts @@ -0,0 +1,45 @@ +import { auth } from "@super/auth/server" +import { headers } from "next/headers" +import { NextRequest, NextResponse } from "next/server" + +import { upsertLocalProjectSchema } from "@/modules/nova/local-projects/contracts" +import { createLocalProject, listLocalProjects } from "@/modules/nova/local-projects/service" + +export const runtime = "nodejs" +export const dynamic = "force-dynamic" + +export async function GET() { + const session = await auth.api.getSession({ headers: await headers() }) + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + try { + const projects = await listLocalProjects(session.user.id) + return NextResponse.json({ projects }) + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed to list local projects" }, + { status: 400 }, + ) + } +} + +export async function POST(request: NextRequest) { + const session = await auth.api.getSession({ headers: await headers() }) + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + const parsed = upsertLocalProjectSchema.safeParse(await request.json().catch(() => null)) + if (!parsed.success) { + return NextResponse.json({ error: "Invalid local project", issues: parsed.error.issues }, { status: 400 }) + } + try { + const project = await createLocalProject(session.user.id, parsed.data) + return NextResponse.json({ project }, { status: 201 }) + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed to create local project" }, + { status: 400 }, + ) + } +} diff --git a/apps/web/app/api/nova/references/route.test.ts b/apps/web/app/api/nova/references/route.test.ts new file mode 100644 index 00000000..afc35360 --- /dev/null +++ b/apps/web/app/api/nova/references/route.test.ts @@ -0,0 +1,47 @@ +import { beforeEach, expect, mock, test } from "bun:test" +import { NextRequest } from "next/server" + +let signedIn = true +let failure: Error | null = null +class ScopeError extends Error { + constructor(message: string, readonly status: number) { super(message) } +} +const search = mock(async (_userId: string, _kind: string, _query: string) => { + if (failure) throw failure + return { items: [{ kind: _kind, id: "fixture_ref", label: _query || "Fixture reference", description: _userId }] } +}) +mock.module("@super/auth/server", () => ({ auth: { api: { getSession: async () => signedIn ? { user: { id: "authenticated_user" } } : null } } })) +mock.module("@/modules/nova/references/service", () => ({ ReferenceServiceError: ScopeError, searchNovaReferences: search })) +const { GET } = await import("./route") + +beforeEach(() => { signedIn = true; failure = null; search.mockClear() }) + +test("reference search requires a signed-in session", async () => { + signedIn = false + expect((await GET(new NextRequest("http://nova.localhost:3003/api/nova/references?kind=files"))).status).toBe(401) + expect(search).not.toHaveBeenCalled() +}) + +test("uses the session actor, not a user ID supplied in the URL", async () => { + const response = await GET(new NextRequest("http://nova.localhost:3003/api/nova/references?kind=threads&q=%20release%20&userId=another_user")) + expect(response.status).toBe(200) + expect(search).toHaveBeenCalledWith("authenticated_user", "threads", "release") + expect(response.headers.get("cache-control")).toBe("no-store") +}) + +test("rejects unsupported kinds and oversized queries", async () => { + expect((await GET(new NextRequest("http://nova.localhost:3003/api/nova/references?kind=credentials"))).status).toBe(400) + expect((await GET(new NextRequest(`http://nova.localhost:3003/api/nova/references?kind=files&q=${"x".repeat(201)}`))).status).toBe(400) + expect(search).not.toHaveBeenCalled() +}) + +test("reports access failures but does not expose internal provider errors", async () => { + failure = new ScopeError("Active membership required", 403) + const denied = await GET(new NextRequest("http://nova.localhost:3003/api/nova/references?kind=threads")) + expect(denied.status).toBe(403) + expect((await denied.json()).error).toBe("Active membership required") + failure = new Error("internal-provider-detail") + const failed = await GET(new NextRequest("http://nova.localhost:3003/api/nova/references?kind=files")) + expect(failed.status).toBe(502) + expect(await failed.text()).not.toContain("internal-provider-detail") +}) diff --git a/apps/web/app/api/nova/references/route.ts b/apps/web/app/api/nova/references/route.ts new file mode 100644 index 00000000..7f117229 --- /dev/null +++ b/apps/web/app/api/nova/references/route.ts @@ -0,0 +1,26 @@ +import { auth } from "@super/auth/server" +import { NextRequest, NextResponse } from "next/server" +import { z } from "zod" + +import { referenceKindSchema } from "@/modules/nova/references/contracts" +import { ReferenceServiceError, searchNovaReferences } from "@/modules/nova/references/service" + +const querySchema = z.object({ kind: referenceKindSchema, q: z.string().max(200).default("") }) + +export const runtime = "nodejs" +export const dynamic = "force-dynamic" + +export async function GET(request: NextRequest) { + const session = await auth.api.getSession({ headers: request.headers }) + if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + const parsed = querySchema.safeParse({ kind: request.nextUrl.searchParams.get("kind"), q: request.nextUrl.searchParams.get("q") ?? "" }) + if (!parsed.success) return NextResponse.json({ error: "Invalid reference search" }, { status: 400 }) + try { + return NextResponse.json(await searchNovaReferences(session.user.id, parsed.data.kind, parsed.data.q.trim()), { headers: { "Cache-Control": "no-store" } }) + } catch (error) { + return NextResponse.json( + { error: error instanceof ReferenceServiceError ? error.message : "Could not load references. Check your repository connection and try again." }, + { status: error instanceof ReferenceServiceError ? error.status : 502 }, + ) + } +} diff --git a/apps/web/app/api/nova/sessions/[sessionId]/local-project/route.ts b/apps/web/app/api/nova/sessions/[sessionId]/local-project/route.ts new file mode 100644 index 00000000..880648a2 --- /dev/null +++ b/apps/web/app/api/nova/sessions/[sessionId]/local-project/route.ts @@ -0,0 +1,57 @@ +import { auth } from "@super/auth/server" +import { headers } from "next/headers" +import { NextRequest, NextResponse } from "next/server" + +import { bindSessionProjectSchema } from "@/modules/nova/local-projects/contracts" +import { + bindSessionLocalProject, + getSessionLocalProject, +} from "@/modules/nova/local-projects/service" + +export const runtime = "nodejs" +export const dynamic = "force-dynamic" + +export async function GET( + _request: NextRequest, + context: { params: Promise<{ sessionId: string }> }, +) { + const session = await auth.api.getSession({ headers: await headers() }) + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + const { sessionId } = await context.params + try { + const project = await getSessionLocalProject(session.user.id, sessionId) + return NextResponse.json({ project }) + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed to load session project" }, + { status: 400 }, + ) + } +} + +export async function PUT( + request: NextRequest, + context: { params: Promise<{ sessionId: string }> }, +) { + const session = await auth.api.getSession({ headers: await headers() }) + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + const { sessionId } = await context.params + const parsed = bindSessionProjectSchema.safeParse(await request.json().catch(() => null)) + if (!parsed.success) { + return NextResponse.json({ error: "Invalid bind payload", issues: parsed.error.issues }, { status: 400 }) + } + try { + const result = await bindSessionLocalProject(session.user.id, sessionId, parsed.data.projectId) + if (!result) return NextResponse.json({ error: "Session not found" }, { status: 404 }) + return NextResponse.json(result) + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed to bind local project" }, + { status: 400 }, + ) + } +} diff --git a/apps/web/app/api/nova/sessions/[sessionId]/messages/route.ts b/apps/web/app/api/nova/sessions/[sessionId]/messages/route.ts new file mode 100644 index 00000000..1c3e7118 --- /dev/null +++ b/apps/web/app/api/nova/sessions/[sessionId]/messages/route.ts @@ -0,0 +1,44 @@ +import { auth } from "@super/auth/server" +import { headers } from "next/headers" +import { NextRequest, NextResponse } from "next/server" +import { z } from "zod" + +import { postSessionMessage } from "@/modules/nova/sessions/service" + +const postMessageSchema = z.object({ + content: z.string().trim().min(1).max(20_000), + clientMessageId: z.string().trim().min(1).max(128).optional(), + surface: z.enum(["web", "desktop"]).optional(), +}) + +export async function POST( + request: NextRequest, + context: { params: Promise<{ sessionId: string }> }, +) { + try { + const session = await auth.api.getSession({ headers: await headers() }) + if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + + const parsed = postMessageSchema.safeParse(await request.json()) + if (!parsed.success) { + return NextResponse.json({ error: "Invalid message", issues: parsed.error.issues }, { status: 400 }) + } + + const { sessionId } = await context.params + const posted = await postSessionMessage({ + userId: session.user.id, + sessionId, + content: parsed.data.content, + clientMessageId: parsed.data.clientMessageId, + surface: parsed.data.surface ?? "web", + }) + if (!posted) return NextResponse.json({ error: "Session not found" }, { status: 404 }) + return NextResponse.json(posted, { status: 201 }) + } catch (error) { + console.error("[api/nova/sessions/:sessionId/messages]", error) + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed to post message" }, + { status: 500 }, + ) + } +} diff --git a/apps/web/app/api/nova/sessions/[sessionId]/route.ts b/apps/web/app/api/nova/sessions/[sessionId]/route.ts new file mode 100644 index 00000000..93e6d563 --- /dev/null +++ b/apps/web/app/api/nova/sessions/[sessionId]/route.ts @@ -0,0 +1,22 @@ +import { auth } from "@super/auth/server" +import { headers } from "next/headers" +import { NextResponse } from "next/server" + +import { getAgentSession } from "@/modules/nova/sessions/service" + +export async function GET( + _request: Request, + context: { params: Promise<{ sessionId: string }> }, +) { + try { + const authSession = await auth.api.getSession({ headers: await headers() }) + if (!authSession?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + const { sessionId } = await context.params + const session = await getAgentSession(authSession.user.id, sessionId) + if (!session) return NextResponse.json({ error: "Session not found" }, { status: 404 }) + return NextResponse.json({ session }) + } catch (error) { + console.error("[api/nova/sessions/:sessionId]", error) + return NextResponse.json({ error: "Failed to load Nova session" }, { status: 500 }) + } +} diff --git a/apps/web/app/api/nova/sessions/[sessionId]/sync/route.ts b/apps/web/app/api/nova/sessions/[sessionId]/sync/route.ts new file mode 100644 index 00000000..1b62eb07 --- /dev/null +++ b/apps/web/app/api/nova/sessions/[sessionId]/sync/route.ts @@ -0,0 +1,38 @@ +import { auth } from "@super/auth/server" +import { headers } from "next/headers" +import { NextRequest, NextResponse } from "next/server" + +import { syncAgentSession } from "@/modules/nova/sessions/service" + +function nonnegativeInteger(value: string | null, fallback: number): number | null { + if (value === null) return fallback + const parsed = Number(value) + return Number.isInteger(parsed) && parsed >= 0 ? parsed : null +} + +export async function GET( + request: NextRequest, + context: { params: Promise<{ sessionId: string }> }, +) { + try { + const authSession = await auth.api.getSession({ headers: await headers() }) + if (!authSession?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + const afterSequence = nonnegativeInteger(request.nextUrl.searchParams.get("after"), 0) + const limit = nonnegativeInteger(request.nextUrl.searchParams.get("limit"), 100) + if (afterSequence === null || limit === null || limit === 0) { + return NextResponse.json({ error: "Invalid sync cursor" }, { status: 400 }) + } + const { sessionId } = await context.params + const sync = await syncAgentSession({ + userId: authSession.user.id, + sessionId, + afterSequence, + limit, + }) + if (!sync) return NextResponse.json({ error: "Session not found" }, { status: 404 }) + return NextResponse.json(sync) + } catch (error) { + console.error("[api/nova/sessions/:sessionId/sync]", error) + return NextResponse.json({ error: "Failed to sync Nova session" }, { status: 500 }) + } +} diff --git a/apps/web/app/api/nova/sessions/[sessionId]/turn/route.ts b/apps/web/app/api/nova/sessions/[sessionId]/turn/route.ts new file mode 100644 index 00000000..76bceeea --- /dev/null +++ b/apps/web/app/api/nova/sessions/[sessionId]/turn/route.ts @@ -0,0 +1,111 @@ +import { auth } from "@super/auth/server" +import { headers } from "next/headers" +import { NextRequest } from "next/server" +import { z } from "zod" + +import { localAttachmentsSchema } from "@/modules/nova/attachments/contracts" +import { ensureHarnessToken } from "@/modules/nova/harness/auth" +import { referencesInputSchema } from "@/modules/nova/references/contracts" +import { runWebTurn } from "@/modules/nova/sessions/turn" + +const turnSchema = z.object({ + content: z.string().trim().min(1).max(20_000), + clientMessageId: z.string().trim().min(1).max(128).optional(), + model: z.string().trim().min(1).max(128).optional(), + provider: z.string().trim().min(1).max(64).optional(), + effort: z.enum(["low", "medium", "high", "xhigh"]).optional(), + mode: z.enum(["agent", "plan", "chat"]).optional(), + references: referencesInputSchema, + localAttachments: localAttachmentsSchema, +}) + +export const runtime = "nodejs" +export const dynamic = "force-dynamic" +export const maxDuration = 120 + +export async function POST( + request: NextRequest, + context: { params: Promise<{ sessionId: string }> }, +) { + const session = await auth.api.getSession({ headers: await headers() }) + if (!session?.user?.id || !session.user.email) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }) + } + + const parsed = turnSchema.safeParse(await request.json()) + if (!parsed.success) { + return new Response(JSON.stringify({ error: "Invalid turn", issues: parsed.error.issues }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }) + } + + let harnessAuth: Awaited> + try { + harnessAuth = await ensureHarnessToken({ + email: session.user.email, + name: session.user.name, + image: session.user.image, + }) + } catch (error) { + return new Response( + JSON.stringify({ + error: error instanceof Error ? error.message : "Failed to authorize harness", + }), + { status: 502, headers: { "Content-Type": "application/json" } }, + ) + } + + const { sessionId } = await context.params + const encoder = new TextEncoder() + const responseHeaders = new Headers({ + "Content-Type": "application/x-ndjson; charset=utf-8", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + "X-Accel-Buffering": "no", + }) + if (harnessAuth.setCookie) { + responseHeaders.append("Set-Cookie", harnessAuth.setCookie) + } + + const stream = new ReadableStream({ + async start(controller) { + const write = (payload: unknown) => { + controller.enqueue(encoder.encode(`${JSON.stringify(payload)}\n`)) + } + try { + for await (const event of runWebTurn({ + userId: session.user.id, + sessionId, + content: parsed.data.content, + clientMessageId: parsed.data.clientMessageId, + model: parsed.data.model, + provider: parsed.data.provider, + effort: parsed.data.effort, + harnessToken: harnessAuth.token, + signal: request.signal, + references: parsed.data.references, + localAttachments: parsed.data.localAttachments, + })) { + write(event) + } + } catch (error) { + write({ + type: "error", + message: error instanceof Error ? error.message : "Turn failed", + }) + write({ type: "finish", reason: "error" }) + } finally { + controller.close() + } + }, + }) + + return new Response(stream, { + status: 200, + headers: responseHeaders, + }) +} diff --git a/apps/web/app/api/nova/sessions/route.ts b/apps/web/app/api/nova/sessions/route.ts new file mode 100644 index 00000000..41ae0e5e --- /dev/null +++ b/apps/web/app/api/nova/sessions/route.ts @@ -0,0 +1,54 @@ +import { auth } from "@super/auth/server" +import { headers } from "next/headers" +import { NextRequest, NextResponse } from "next/server" +import { z } from "zod" + +import { createAgentSession, listAgentSessions } from "@/modules/nova/sessions/service" + +const createSessionSchema = z.object({ + objective: z.string().trim().min(1).max(20_000), + mode: z.string().trim().min(1).max(64).optional(), + surface: z.enum(["web", "desktop"]).optional(), + localProjectId: z.string().trim().min(1).max(64).optional().nullable(), +}) + +async function currentUserId() { + const session = await auth.api.getSession({ headers: await headers() }) + return session?.user?.id ?? null +} + +export async function GET() { + try { + const userId = await currentUserId() + if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + return NextResponse.json({ sessions: await listAgentSessions(userId) }) + } catch (error) { + console.error("[api/nova/sessions]", error) + return NextResponse.json({ error: "Failed to load Nova sessions" }, { status: 500 }) + } +} + +export async function POST(request: NextRequest) { + try { + const userId = await currentUserId() + if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + const parsed = createSessionSchema.safeParse(await request.json()) + if (!parsed.success) { + return NextResponse.json({ error: "Invalid session", issues: parsed.error.issues }, { status: 400 }) + } + const session = await createAgentSession({ + userId, + objective: parsed.data.objective, + mode: parsed.data.mode, + surface: parsed.data.surface ?? "web", + localProjectId: parsed.data.localProjectId, + }) + return NextResponse.json({ session }, { status: 201 }) + } catch (error) { + console.error("[api/nova/sessions]", error) + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed to create Nova session" }, + { status: 500 }, + ) + } +} diff --git a/apps/web/app/api/nova/settings/activity/route.ts b/apps/web/app/api/nova/settings/activity/route.ts new file mode 100644 index 00000000..8d7c0ffb --- /dev/null +++ b/apps/web/app/api/nova/settings/activity/route.ts @@ -0,0 +1,27 @@ +import { auth } from "@super/auth/server" +import { headers } from "next/headers" +import { NextRequest, NextResponse } from "next/server" + +import { getSettingsActivity } from "@/modules/nova/settings/service" + +export const runtime = "nodejs" +export const dynamic = "force-dynamic" + +export async function GET(request: NextRequest) { + const session = await auth.api.getSession({ headers: await headers() }) + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const range = Number(request.nextUrl.searchParams.get("range") ?? "30") + try { + const activity = await getSettingsActivity(session.user.id, Number.isFinite(range) ? range : 30) + return NextResponse.json(activity, { headers: { "Cache-Control": "no-store" } }) + } catch (error) { + const status = (error as { statusCode?: number })?.statusCode ?? 500 + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed to load activity" }, + { status }, + ) + } +} diff --git a/apps/web/app/api/nova/settings/usage/route.ts b/apps/web/app/api/nova/settings/usage/route.ts new file mode 100644 index 00000000..275e26b2 --- /dev/null +++ b/apps/web/app/api/nova/settings/usage/route.ts @@ -0,0 +1,31 @@ +import { auth } from "@super/auth/server" +import { headers } from "next/headers" +import { NextResponse } from "next/server" + +import { getSettingsUsage } from "@/modules/nova/settings/service" + +export const runtime = "nodejs" +export const dynamic = "force-dynamic" + +export async function GET() { + const session = await auth.api.getSession({ headers: await headers() }) + if (!session?.user?.id || !session.user.email) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + try { + const usage = await getSettingsUsage({ + userId: session.user.id, + email: session.user.email, + name: session.user.name, + image: session.user.image, + }) + return NextResponse.json(usage, { headers: { "Cache-Control": "no-store" } }) + } catch (error) { + const status = (error as { statusCode?: number })?.statusCode ?? 500 + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed to load usage" }, + { status }, + ) + } +} diff --git a/apps/web/modules/components/login-ui.tsx b/apps/web/modules/components/login-ui.tsx index 75b51cfe..39c8651c 100644 --- a/apps/web/modules/components/login-ui.tsx +++ b/apps/web/modules/components/login-ui.tsx @@ -61,9 +61,11 @@ const LoginUI=()=> { const handleGithubLogin = async ()=>{ setIsLoading(true) try { + const isNovaHost = window.location.hostname === "nova.supercodeai.tech" + || window.location.hostname === "nova.localhost" await signIn.social({ provider:"github", - callbackURL: "/dashboard" + callbackURL: isNovaHost ? "/" : "/dashboard" }) } catch (error) { console.error("Login error:", error) diff --git a/apps/web/modules/components/utils/auth-utils.ts b/apps/web/modules/components/utils/auth-utils.ts index 7d81a1c1..8a126499 100644 --- a/apps/web/modules/components/utils/auth-utils.ts +++ b/apps/web/modules/components/utils/auth-utils.ts @@ -4,7 +4,7 @@ import { auth } from "@/lib/auth" import { headers } from "next/headers" import { redirect } from "next/navigation" -export const requireAuth = async () => { +export const requireAuth = async (loginPath = "/login") => { let lastError: unknown for (let attempt = 0; attempt < 2; attempt++) { try { @@ -13,7 +13,7 @@ export const requireAuth = async () => { }) if (!session) { - redirect("/login") + redirect(loginPath) } return session @@ -24,16 +24,17 @@ export const requireAuth = async () => { } } } - redirect("/login") + void lastError + redirect(loginPath) } -export const requireUnAuth = async () =>{ +export const requireUnAuth = async (authenticatedPath = "/") =>{ const session = await auth.api.getSession({ headers:await headers() }) if(session){ - redirect("/") + redirect(authenticatedPath) } return session diff --git a/apps/web/modules/integrations/lib/app-url.ts b/apps/web/modules/integrations/lib/app-url.ts index 5b26dc23..9bff2383 100644 --- a/apps/web/modules/integrations/lib/app-url.ts +++ b/apps/web/modules/integrations/lib/app-url.ts @@ -7,10 +7,53 @@ export function getAppBaseUrl(): string { process.env.BETTER_AUTH_URL || process.env.NEXT_PUBLIC_APP_BASE_URL || process.env.NEXT_PUBLIC_APP_URL || - "http://localhost:3000" + "http://localhost:3003" return url.replace(/\/$/, "") } +/** + * Nova web surface base URL (connections / settings live here, not /dashboard/*). + * Prefer dedicated env, then nova host derived from BETTER_AUTH_URL, then app base. + */ +export function getNovaWebBaseUrl(requestOrigin?: string | null): string { + const explicit = + process.env.NOVA_WEB_URL?.trim() || + process.env.NEXT_PUBLIC_NOVA_WEB_URL?.trim() + if (explicit) return explicit.replace(/\/$/, "") + + if (requestOrigin) { + try { + const origin = new URL(requestOrigin).origin + if (origin) return origin.replace(/\/$/, "") + } catch { + // fall through + } + } + + const app = getAppBaseUrl() + try { + const parsed = new URL(app) + // Local dashboard host → Nova host on the same port. + if (parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1") { + parsed.hostname = "nova.localhost" + return parsed.origin + } + // Production app host → nova subdomain when obvious. + if (parsed.hostname === "supercodeai.tech" || parsed.hostname === "www.supercodeai.tech") { + parsed.hostname = "nova.supercodeai.tech" + return parsed.origin + } + if (parsed.hostname.endsWith(".supercodeai.tech") && !parsed.hostname.startsWith("nova.")) { + parsed.hostname = "nova.supercodeai.tech" + return parsed.origin + } + } catch { + // fall through + } + + return app +} + export function getIntegrationsSettingsUrl(params?: { connected?: "slack" | "linear" | "github" error?: string @@ -23,3 +66,20 @@ export function getIntegrationsSettingsUrl(params?: { const q = sp.toString() return q ? `${base}?${q}` : base } + +/** Post-OAuth landing page inside Nova web (Connections). */ +export function getNovaConnectionsUrl( + params?: { + connected?: "slack" | "linear" | "github" + error?: string + }, + requestOrigin?: string | null, +): string { + const base = `${getNovaWebBaseUrl(requestOrigin)}/connections` + if (!params) return base + const sp = new URLSearchParams() + if (params.connected) sp.set("connected", params.connected) + if (params.error) sp.set("integration_error", params.error) + const q = sp.toString() + return q ? `${base}?${q}` : base +} diff --git a/apps/web/modules/integrations/lib/callback-flow.ts b/apps/web/modules/integrations/lib/callback-flow.ts index df1ded92..81d587e2 100644 --- a/apps/web/modules/integrations/lib/callback-flow.ts +++ b/apps/web/modules/integrations/lib/callback-flow.ts @@ -1,5 +1,5 @@ -import { getIntegrationsSettingsUrl } from "./app-url" -import { verifyOAuthState } from "./oauth-state" +import { getIntegrationsSettingsUrl, getNovaConnectionsUrl } from "./app-url" +import { verifyOAuthState, type OAuthReturnTo } from "./oauth-state" import { getComposioConnectedAccount, } from "./composio" @@ -8,13 +8,24 @@ import { ensureUserOrganization } from "./org" import type { IntegrationProvider } from "../actions/schema" import { NextResponse } from "next/server" -function completionUrl(returnTo: "desktop" | "web", provider: IntegrationProvider, error?: string) { +function completionUrl( + returnTo: OAuthReturnTo, + provider: IntegrationProvider, + error?: string, + requestOrigin?: string | null, +) { if (returnTo === "desktop") { const url = new URL("supercode://composio/connected") url.searchParams.set("provider", provider) if (error) url.searchParams.set("error", error) return url } + if (returnTo === "nova") { + return getNovaConnectionsUrl( + error ? { error } : { connected: provider }, + requestOrigin, + ) + } return getIntegrationsSettingsUrl(error ? { error } : { connected: provider }) } @@ -25,8 +36,10 @@ function completionUrl(returnTo: "desktop" | "web", provider: IntegrationProvide export async function handleComposioCallback(params: { provider: IntegrationProvider searchParams: URLSearchParams + /** Origin of the callback request so Nova redirects stay on nova.localhost / nova.supercodeai.tech. */ + requestOrigin?: string | null }): Promise { - const { provider, searchParams } = params + const { provider, searchParams, requestOrigin } = params const status = searchParams.get("status") || @@ -37,25 +50,27 @@ export async function handleComposioCallback(params: { searchParams.get("error_description") || searchParams.get("integration_error") + // Peek state early so error redirects honor returnTo (nova vs dashboard). + const state = searchParams.get("state") + const peeked = state ? verifyOAuthState(state, provider) : null + const returnTo: OAuthReturnTo = peeked?.returnTo ?? "web" + if (error || (status && status !== "success" && status !== "ACTIVE")) { return NextResponse.redirect( - getIntegrationsSettingsUrl({ - error: error || `${provider}_oauth_denied`, - }), + completionUrl(returnTo, provider, error || `${provider}_oauth_denied`, requestOrigin), ) } - const state = searchParams.get("state") if (!state) { return NextResponse.redirect( - getIntegrationsSettingsUrl({ error: "missing_oauth_params" }), + completionUrl(returnTo, provider, "missing_oauth_params", requestOrigin), ) } - const verified = verifyOAuthState(state, provider) + const verified = peeked ?? verifyOAuthState(state, provider) if (!verified) { return NextResponse.redirect( - getIntegrationsSettingsUrl({ error: "invalid_state" }), + completionUrl(returnTo, provider, "invalid_state", requestOrigin), ) } @@ -65,7 +80,9 @@ export async function handleComposioCallback(params: { searchParams.get("connectedAccountID") if (!connectedAccountId) { - return NextResponse.redirect(getIntegrationsSettingsUrl({ error: "missing_connected_account" })) + return NextResponse.redirect( + completionUrl(verified.returnTo, provider, "missing_connected_account", requestOrigin), + ) } try { @@ -78,7 +95,14 @@ export async function handleComposioCallback(params: { account.toolkitSlug !== provider || (account.userId && account.userId !== expectedEntityId) ) { - return NextResponse.redirect(completionUrl(verified.returnTo, provider, `${provider}_connection_invalid`)) + return NextResponse.redirect( + completionUrl( + verified.returnTo, + provider, + `${provider}_connection_invalid`, + requestOrigin, + ), + ) } await upsertComposioIntegration({ @@ -88,9 +112,18 @@ export async function handleComposioCallback(params: { teamName: account?.displayName ?? null, }) - return NextResponse.redirect(completionUrl(verified.returnTo, provider)) + return NextResponse.redirect( + completionUrl(verified.returnTo, provider, undefined, requestOrigin), + ) } catch (err) { console.error(`Composio ${provider} callback failed:`, err) - return NextResponse.redirect(completionUrl(verified.returnTo, provider, `${provider}_connect_failed`)) + return NextResponse.redirect( + completionUrl( + verified.returnTo, + provider, + `${provider}_connect_failed`, + requestOrigin, + ), + ) } } diff --git a/apps/web/modules/integrations/lib/composio.ts b/apps/web/modules/integrations/lib/composio.ts index 16830bae..3a4f7a47 100644 --- a/apps/web/modules/integrations/lib/composio.ts +++ b/apps/web/modules/integrations/lib/composio.ts @@ -70,13 +70,14 @@ export function composioEntityIdForOrg(organizationId: string): string { } function authConfigIdFor(provider: IntegrationProvider): string | undefined { - if (provider === "slack") { - return process.env.COMPOSIO_SLACK_AUTH_CONFIG_ID?.trim() || undefined - } - if (provider === "linear") { - return process.env.COMPOSIO_LINEAR_AUTH_CONFIG_ID?.trim() || undefined - } - return process.env.COMPOSIO_GITHUB_AUTH_CONFIG_ID?.trim() || undefined + const raw = (() => { + if (provider === "slack") return process.env.COMPOSIO_SLACK_AUTH_CONFIG_ID + if (provider === "linear") return process.env.COMPOSIO_LINEAR_AUTH_CONFIG_ID + return process.env.COMPOSIO_GITHUB_AUTH_CONFIG_ID + })()?.trim() + // Ignore empty / obviously truncated ids (e.g. trailing underscore stubs). + if (!raw || raw.length < 8 || raw.endsWith("_")) return undefined + return raw } export type ComposioConnectStart = { diff --git a/apps/web/modules/integrations/lib/connect-flow.ts b/apps/web/modules/integrations/lib/connect-flow.ts index 4a14fb40..995cfdb2 100644 --- a/apps/web/modules/integrations/lib/connect-flow.ts +++ b/apps/web/modules/integrations/lib/connect-flow.ts @@ -4,13 +4,13 @@ import { isComposioConfigured, startComposioConnect, } from "./composio" -import { createOAuthState } from "./oauth-state" +import { createOAuthState, type OAuthReturnTo } from "./oauth-state" import type { IntegrationProvider } from "../actions/schema" export async function beginProviderConnect(params: { userId: string provider: IntegrationProvider - returnTo?: "desktop" | "web" + returnTo?: OAuthReturnTo }): Promise<{ ok: true redirectUrl: string diff --git a/apps/web/modules/integrations/lib/desktop-composio.ts b/apps/web/modules/integrations/lib/desktop-composio.ts index 1dd87ec5..b8603fed 100644 --- a/apps/web/modules/integrations/lib/desktop-composio.ts +++ b/apps/web/modules/integrations/lib/desktop-composio.ts @@ -56,9 +56,13 @@ export async function listDesktopComposioApps(userId: string) { : a.connected ? -1 : 1) } -export async function beginDesktopComposioConnect(userId: string, slug: string) { +export async function beginDesktopComposioConnect( + userId: string, + slug: string, + returnTo: "desktop" | "web" | "nova" = "desktop", +) { const provider = integrationProviderSchema.parse(slug) - return beginProviderConnect({ userId, provider, returnTo: "desktop" }) + return beginProviderConnect({ userId, provider, returnTo }) } export async function disconnectDesktopComposio(userId: string, connectedAccountId: string) { diff --git a/apps/web/modules/integrations/lib/oauth-state.test.ts b/apps/web/modules/integrations/lib/oauth-state.test.ts index 829e2110..21e54b91 100644 --- a/apps/web/modules/integrations/lib/oauth-state.test.ts +++ b/apps/web/modules/integrations/lib/oauth-state.test.ts @@ -23,6 +23,14 @@ describe("Composio OAuth state", () => { expect(verifyOAuthState(state, "slack")).toBeNull() }) + test("preserves the Nova return target for Linear", () => { + const state = createOAuthState("user_2", "linear", "nova") + expect(verifyOAuthState(state, "linear")).toEqual({ + userId: "user_2", + returnTo: "nova", + }) + }) + test("rejects a modified signature", () => { const state = createOAuthState("user_1", "slack", "web") expect(verifyOAuthState(`${state}changed`, "slack")).toBeNull() diff --git a/apps/web/modules/integrations/lib/oauth-state.ts b/apps/web/modules/integrations/lib/oauth-state.ts index 9d0afafa..c9e98962 100644 --- a/apps/web/modules/integrations/lib/oauth-state.ts +++ b/apps/web/modules/integrations/lib/oauth-state.ts @@ -2,6 +2,8 @@ import { createHmac, timingSafeEqual } from "crypto" const STATE_TTL_MS = 10 * 60 * 1000 // 10 minutes +export type OAuthReturnTo = "desktop" | "web" | "nova" + function getStateSecret(): string { const secret = process.env.BETTER_AUTH_SECRET || @@ -12,14 +14,20 @@ function getStateSecret(): string { return secret } +function normalizeReturnTo(value: unknown): OAuthReturnTo { + if (value === "desktop") return "desktop" + if (value === "nova") return "nova" + return "web" +} + /** * Signed CSRF state: base64url(payload).base64url(hmac) - * payload = { userId, provider, exp } + * payload = { userId, provider, returnTo, exp } */ export function createOAuthState( userId: string, provider: "slack" | "linear" | "github", - returnTo: "desktop" | "web" = "web", + returnTo: OAuthReturnTo = "web", ): string { const payload = Buffer.from( JSON.stringify({ @@ -41,7 +49,7 @@ export function createOAuthState( export function verifyOAuthState( state: string, expectedProvider: "slack" | "linear" | "github", -): { userId: string; returnTo: "desktop" | "web" } | null { +): { userId: string; returnTo: OAuthReturnTo } | null { const parts = state.split(".") if (parts.length !== 2) return null const [payload, sig] = parts @@ -70,7 +78,7 @@ export function verifyOAuthState( if (typeof data.exp !== "number" || Date.now() > data.exp) return null return { userId: data.userId, - returnTo: data.returnTo === "desktop" ? "desktop" : "web", + returnTo: normalizeReturnTo(data.returnTo), } } catch { return null diff --git a/apps/web/modules/nova-web/api.ts b/apps/web/modules/nova-web/api.ts new file mode 100644 index 00000000..84ea225d --- /dev/null +++ b/apps/web/modules/nova-web/api.ts @@ -0,0 +1,217 @@ +import type { + AgentSessionSummary, + ApprovalRequest, + ConnectorStatus, + SessionDetail, + SyncCursor, + TimelineActivity, + TimelineMessage, +} from "@/modules/nova-web/types" +import type { LocalAttachment } from "@/modules/nova/attachments/contracts" +import type { NovaReference } from "@/modules/nova/references/contracts" + +export async function requestJson(url: string, init?: RequestInit): Promise { + const response = await fetch(url, init) + const payload = await response.json().catch(() => ({})) + if (!response.ok) { + throw new Error( + typeof payload === "object" && payload && "error" in payload && typeof payload.error === "string" + ? payload.error + : "Request failed", + ) + } + return payload as T +} + +export function listSessions() { + return requestJson<{ sessions: AgentSessionSummary[] }>("/api/nova/sessions") +} + +export function createSession(objective: string, options?: { localProjectId?: string | null }) { + return requestJson<{ session: AgentSessionSummary }>("/api/nova/sessions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + objective, + mode: "chat", + surface: "web", + localProjectId: options?.localProjectId ?? undefined, + }), + }) +} + +export type LocalProjectDto = { + id: string + displayName: string + rootName: string + fileCount: number + truncated: boolean + repositoryFullName: string | null + status: string + lastUsedAt: string | null + updatedAt: string + paths?: string[] +} + +export function listLocalProjects() { + return requestJson<{ projects: LocalProjectDto[] }>("/api/nova/local-projects") +} + +export function createLocalProject(input: { + displayName: string + rootName: string + paths: string[] + truncated?: boolean + repositoryFullName?: string | null +}) { + return requestJson<{ project: LocalProjectDto }>("/api/nova/local-projects", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + }) +} + +export function updateLocalProject( + projectId: string, + input: { + displayName?: string + paths?: string[] + truncated?: boolean + repositoryFullName?: string | null + status?: "active" | "archived" + }, +) { + return requestJson<{ project: LocalProjectDto }>(`/api/nova/local-projects/${projectId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + }) +} + +export function bindSessionLocalProject(sessionId: string, projectId: string | null) { + return requestJson<{ sessionId: string; localProjectId: string | null; project: LocalProjectDto | null }>( + `/api/nova/sessions/${sessionId}/local-project`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ projectId }), + }, + ) +} + +export function getSession(sessionId: string) { + return requestJson<{ session: SessionDetail }>(`/api/nova/sessions/${sessionId}`) +} + +export function syncSession(sessionId: string, after: number, limit = 200) { + return requestJson( + `/api/nova/sessions/${sessionId}/sync?after=${Math.max(0, after)}&limit=${limit}`, + ) +} + +export function listApprovals() { + return requestJson<{ approvals: ApprovalRequest[] } | { contractVersion: string; approvals: ApprovalRequest[] }>( + "/api/nova/approvals", + ) +} + +export function listConnectors() { + return requestJson<{ connectors: ConnectorStatus[] }>("/api/nova/connectors") +} + +export function decideApproval(approval: ApprovalRequest, decision: "approved" | "denied") { + return requestJson(`/api/nova/approvals/${approval.id}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + decision, + sessionId: approval.sessionId, + runId: approval.runId, + toolInvocationId: approval.toolInvocationId, + capability: approval.capability, + normalizedArgsHash: approval.normalizedArgsHash, + }), + }) +} + +export type TurnEvent = + | { type: "status"; phase: string; message: string; runId?: string; model?: string } + | { + type: "user_message" + message: Omit + runId: string + latestSequence: number + } + | { type: "text"; content: string } + | { type: "reasoning"; content: string } + | { type: "activity"; activity: Omit } + | { type: "error"; message: string } + | { type: "finish"; reason: string; runId?: string; latestSequence?: number; model?: string } + +export async function* streamTurn( + sessionId: string, + content: string, + options?: { + clientMessageId?: string + model?: string + provider?: string + effort?: "low" | "medium" | "high" | "xhigh" + mode?: "agent" | "plan" | "chat" + references?: NovaReference[] + localAttachments?: LocalAttachment[] + }, +): AsyncGenerator { + const response = await fetch(`/api/nova/sessions/${sessionId}/turn`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + content, + clientMessageId: options?.clientMessageId, + model: options?.model, + provider: options?.provider, + effort: options?.effort, + mode: options?.mode, + // Local workspace ids are attached as localAttachments client-side; never send them as GitHub refs. + references: options?.references + ?.filter((reference) => !reference.id.startsWith("local:")) + .map(({ kind, id }) => ({ kind, id })), + localAttachments: options?.localAttachments?.map((file) => ({ + id: file.id, + kind: file.kind, + name: file.name, + mediaType: file.mediaType, + size: file.size, + text: file.text, + dataBase64: file.dataBase64, + })), + }), + }) + if (!response.ok || !response.body) { + const payload = await response.json().catch(() => ({})) + throw new Error( + typeof payload === "object" && payload && "error" in payload && typeof payload.error === "string" + ? payload.error + : "Turn failed", + ) + } + + const reader = response.body.getReader() + const decoder = new TextDecoder() + let buffer = "" + + while (true) { + const { done, value } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split("\n") + buffer = lines.pop() ?? "" + for (const line of lines) { + const trimmed = line.trim() + if (!trimmed) continue + yield JSON.parse(trimmed) as TurnEvent + } + } + + const tail = buffer.trim() + if (tail) yield JSON.parse(tail) as TurnEvent +} diff --git a/apps/web/modules/nova-web/components/account-menu.tsx b/apps/web/modules/nova-web/components/account-menu.tsx new file mode 100644 index 00000000..ec43ef83 --- /dev/null +++ b/apps/web/modules/nova-web/components/account-menu.tsx @@ -0,0 +1,169 @@ +"use client" + +import { useEffect, useRef, useState } from "react" +import { + Activity, + ChevronUp, + CreditCard, + HelpCircle, + LogOut, + Settings2, + UserRound, +} from "lucide-react" + +import { cn } from "@/lib/utils" +import { signOut } from "@/lib/auth-client" +import type { NovaUser } from "@/modules/nova-web/types" + +export function AccountMenu({ + user, + onNavigate, + align = "left", + className, +}: { + user: NovaUser + onNavigate: (href: string) => void + align?: "left" | "right" + className?: string +}) { + const [open, setOpen] = useState(false) + const [busy, setBusy] = useState(false) + const ref = useRef(null) + + useEffect(() => { + if (!open) return + function onPointer(event: MouseEvent) { + if (!ref.current?.contains(event.target as Node)) setOpen(false) + } + function onKey(event: KeyboardEvent) { + if (event.key === "Escape") setOpen(false) + } + document.addEventListener("mousedown", onPointer) + document.addEventListener("keydown", onKey) + return () => { + document.removeEventListener("mousedown", onPointer) + document.removeEventListener("keydown", onKey) + } + }, [open]) + + async function handleSignOut() { + setBusy(true) + try { + await signOut() + window.location.href = "/login" + } catch { + setBusy(false) + } + } + + return ( +
+ + + {open ? ( +
+ } + label="Settings" + hint="⌘," + onClick={() => { + setOpen(false) + onNavigate("/settings") + }} + /> + } + label="Profile" + onClick={() => { + setOpen(false) + onNavigate("/settings?section=profile") + }} + /> + } + label="Activity" + onClick={() => { + setOpen(false) + onNavigate("/settings?section=activity") + }} + /> + } + label="Usage" + onClick={() => { + setOpen(false) + onNavigate("/settings?section=usage") + }} + /> + } + label="Support" + onClick={() => { + setOpen(false) + window.open("https://supercode.ai", "_blank", "noopener,noreferrer") + }} + /> +
+ } + label={busy ? "Signing out…" : "Log out"} + danger + onClick={() => void handleSignOut()} + /> +
+ ) : null} +
+ ) +} + +function MenuRow({ + icon, + label, + hint, + danger, + onClick, +}: { + icon: React.ReactNode + label: string + hint?: string + danger?: boolean + onClick: () => void +}) { + return ( + + ) +} diff --git a/apps/web/modules/nova-web/components/approvals.tsx b/apps/web/modules/nova-web/components/approvals.tsx new file mode 100644 index 00000000..06453c5c --- /dev/null +++ b/apps/web/modules/nova-web/components/approvals.tsx @@ -0,0 +1,57 @@ +"use client" + +import { ApprovalCard, NovaMark } from "@/modules/nova-web/components/timeline" +import type { ApprovalRequest } from "@/modules/nova-web/types" + +export function ApprovalsView({ + approvals, + decidingApprovalId, + onDecision, + onOpenSession, +}: { + approvals: ApprovalRequest[] + decidingApprovalId: string | null + onDecision: (approval: ApprovalRequest, decision: "approved" | "denied") => void + onOpenSession: (sessionId: string) => void +}) { + const pending = approvals.filter((approval) => approval.status === "pending") + + return ( +
+
+
+ +
+

Approvals

+

+ {pending.length} pending · company mutations need an explicit decision +

+
+
+
+ {pending.map((approval) => ( +
+ + +
+ ))} + {pending.length === 0 ? ( +
+ No pending approvals. +
+ ) : null} +
+
+
+ ) +} diff --git a/apps/web/modules/nova-web/components/composer.tsx b/apps/web/modules/nova-web/components/composer.tsx new file mode 100644 index 00000000..6e3633af --- /dev/null +++ b/apps/web/modules/nova-web/components/composer.tsx @@ -0,0 +1,1025 @@ +"use client" + +import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react" +import { + ArrowUp, + AtSign, + AudioLines, + Check, + ChevronDown, + ChevronRight, + Cpu, + File, + FileText, + Image as ImageIcon, + Plus, + Search, + Square, + X, + Zap, +} from "lucide-react" + +import { cn } from "@/lib/utils" +import { imageDataUrl, type LocalAttachment } from "@/modules/nova/attachments/contracts" +import type { NovaReference, ReferenceKind } from "@/modules/nova/references/contracts" +import { + filesFromClipboard, + filesFromDataTransfer, + mergeLocalAttachments, + pickLocalFiles, + readLocalFile, +} from "@/modules/nova-web/local-files" +import { + isLocalFileReferenceId, + localPathFromReferenceId, + pickLocalWorkspaceFolder, + readLocalWorkspaceFile, + type LocalWorkspaceState, +} from "@/modules/nova-web/local-workspace" +import { + addReference, + filterReferenceCategories, + insertMentionTrigger, + moveMentionIndex, + parseMentionQuery, + REFERENCE_CATEGORIES, + replaceMentionQuery, + type MentionQuery, +} from "@/modules/nova-web/mention-helpers" +import { + BYOK_PROVIDERS, + CLOUD_MODELS, + DEFAULT_NOVA_MODEL, + DEFAULT_NOVA_PROVIDER, + HARNESS_PROVIDERS, + menuId, + modelChipLabel, + modelsForProvider, + NOVA_EFFORTS, + type HarnessProvider, + type NovaAgentMode, + type NovaEffort, + type NovaModelEntry, +} from "@/modules/nova-web/models" + +import { MentionPicker, mentionOptionId, ReferenceIcon, useReferenceSearch } from "./mention-picker" + +const EMPTY_REFERENCES: NovaReference[] = [] +const EMPTY_LOCAL_FILES: LocalAttachment[] = [] + +type MentionState = { + draft: string + range: MentionQuery + kind: ReferenceKind | null + activeIndex: number + session: number +} + +export function Composer({ + value, + onChange, + onSubmit, + onStop, + large = false, + disabled = false, + streaming = false, + placeholder, + className, + model = DEFAULT_NOVA_MODEL, + provider = DEFAULT_NOVA_PROVIDER, + effort = "high", + mode = "agent", + onModelChange, + onEffortChange, + onModeChange, + references = EMPTY_REFERENCES, + onReferencesChange, + localFiles = EMPTY_LOCAL_FILES, + onLocalFilesChange, + onLocalProjectChange, +}: { + value: string + onChange: (value: string) => void + onSubmit: () => void + onStop?: () => void + large?: boolean + disabled?: boolean + streaming?: boolean + placeholder?: string + className?: string + model?: string + provider?: HarnessProvider + effort?: NovaEffort + mode?: NovaAgentMode + onModelChange?: (selection: { provider: HarnessProvider; model: string }) => void + onEffortChange?: (effort: NovaEffort) => void + onModeChange?: (mode: NovaAgentMode) => void + references?: NovaReference[] + onReferencesChange?: (references: NovaReference[]) => void + localFiles?: LocalAttachment[] + onLocalFilesChange?: (files: LocalAttachment[]) => void + onLocalProjectChange?: (project: LocalWorkspaceState) => void +}) { + const textareaRef = useRef(null) + const attachRef = useRef(null) + const attachMenuRef = useRef(null) + const pickerRef = useRef(null) + const composingRef = useRef(false) + const [attachMenuOpen, setAttachMenuOpen] = useState(false) + const [localFileBusy, setLocalFileBusy] = useState(false) + const [localFileError, setLocalFileError] = useState(null) + const [dragOver, setDragOver] = useState(false) + const dragDepthRef = useRef(0) + const sessionRef = useRef(0) + const pendingCaretRef = useRef<{ value: string; caret: number } | null>(null) + const [mention, setMention] = useState(null) + const [referenceAnnouncement, setReferenceAnnouncement] = useState("") + const pickerId = useId() + const hintId = `${pickerId}-hint` + const referencesEnabled = !!onReferencesChange && !disabled && !streaming + const localFilesEnabled = !!onLocalFilesChange && !disabled && !streaming && !localFileBusy + const attachEnabled = referencesEnabled || localFilesEnabled + const pickerOpen = referencesEnabled && mention !== null && mention.draft === value + const categories = filterReferenceCategories(pickerOpen ? mention.range.query : "") + const search = useReferenceSearch(pickerOpen ? mention.kind : null, pickerOpen ? mention.range.query : "", mention?.session ?? 0) + const optionCount = pickerOpen && mention.kind ? search.items.length : categories.length + const activeIndex = Math.max(0, Math.min(mention?.activeIndex ?? 0, optionCount - 1)) + const canSend = (value.trim().length > 0 || localFiles.length > 0) && !disabled && !streaming && !localFileBusy + const effortMeta = NOVA_EFFORTS.find((item) => item.id === effort) ?? NOVA_EFFORTS[2]! + const chip = modelChipLabel(provider, model) + + const dismissMention = useCallback(() => setMention(null), []) + + useEffect(() => { + if (!pickerOpen && !attachMenuOpen) return + function onPointer(event: PointerEvent) { + const target = event.target as Node + if (pickerRef.current?.contains(target) || textareaRef.current?.contains(target) || attachRef.current?.contains(target) || attachMenuRef.current?.contains(target)) return + dismissMention() + setAttachMenuOpen(false) + } + document.addEventListener("pointerdown", onPointer) + return () => document.removeEventListener("pointerdown", onPointer) + }, [attachMenuOpen, dismissMention, pickerOpen]) + + useLayoutEffect(() => { + const pending = pendingCaretRef.current + const textarea = textareaRef.current + if (!pending || !textarea || pending.value !== value) return + pendingCaretRef.current = null + textarea.focus({ preventScroll: true }) + textarea.setSelectionRange(pending.caret, pending.caret) + }, [value, mention]) + + function syncMention(textarea: HTMLTextAreaElement, userInput: boolean) { + if (!userInput && pendingCaretRef.current) return + const draft = textarea.value + const range = parseMentionQuery(draft, textarea.selectionStart, textarea.selectionEnd) + if (!referencesEnabled || !range) { + setMention(null) + return + } + const session = ++sessionRef.current + setMention((previous) => { + const current = previous?.draft === value || previous?.draft === draft ? previous : null + if (!current && !userInput) return null + const sameTrigger = current?.range.start === range.start + return { + draft, + range, + kind: sameTrigger ? current.kind : null, + activeIndex: sameTrigger && current.range.query === range.query ? current.activeIndex : 0, + session: sameTrigger ? current.session : session, + } + }) + } + + function openMention() { + const textarea = textareaRef.current + if (!referencesEnabled || !textarea) return + setAttachMenuOpen(false) + const range = parseMentionQuery(value, textarea.selectionStart, textarea.selectionEnd) + const edit = range + ? { value, caret: textarea.selectionStart } + : insertMentionTrigger(value, textarea.selectionStart, textarea.selectionEnd) + const nextRange = range ?? parseMentionQuery(edit.value, edit.caret) + if (!nextRange) return + pendingCaretRef.current = edit + setMention({ draft: edit.value, range: nextRange, kind: null, activeIndex: 0, session: ++sessionRef.current }) + if (edit.value !== value) onChange(edit.value) + } + + function openAttachMenu() { + if (!attachEnabled) return + if (localFilesEnabled && referencesEnabled) { + setAttachMenuOpen((open) => !open) + dismissMention() + return + } + if (localFilesEnabled) { + void handlePickLocalFiles() + return + } + openMention() + } + + async function ingestLocalFiles(picked: File[], source: "picker" | "paste" | "drop") { + if (!localFilesEnabled || !onLocalFilesChange || picked.length === 0) return + setAttachMenuOpen(false) + setLocalFileError(null) + setLocalFileBusy(true) + try { + const read = await Promise.all(picked.map((file) => readLocalFile(file))) + const ok = read.flatMap((item) => (item.ok ? [item.attachment] : [])) + const failed = read.flatMap((item) => (item.ok ? [] : [`${item.name}: ${item.error}`])) + const merged = mergeLocalAttachments(localFiles, ok) + onLocalFilesChange(merged.attachments) + const notes = [...failed, ...merged.skipped] + if (notes.length > 0) setLocalFileError(notes.slice(0, 3).join(" · ")) + const verb = source === "paste" ? "pasted" : source === "drop" ? "dropped" : "attached" + setReferenceAnnouncement( + ok.length > 0 + ? `${ok.length} file${ok.length === 1 ? "" : "s"} ${verb}.` + : "No files attached.", + ) + } catch (error) { + setLocalFileError(error instanceof Error ? error.message : "Could not attach files") + } finally { + setLocalFileBusy(false) + textareaRef.current?.focus({ preventScroll: true }) + } + } + + async function handlePickLocalFiles() { + if (!localFilesEnabled) return + try { + const picked = await pickLocalFiles() + await ingestLocalFiles(picked, "picker") + } catch (error) { + setLocalFileError(error instanceof Error ? error.message : "Could not open the file picker") + } + } + + function onComposerDragEnter(event: React.DragEvent) { + if (!localFilesEnabled) return + if (![...event.dataTransfer.types].includes("Files")) return + event.preventDefault() + dragDepthRef.current += 1 + setDragOver(true) + } + + function onComposerDragLeave(event: React.DragEvent) { + if (!localFilesEnabled) return + event.preventDefault() + dragDepthRef.current = Math.max(0, dragDepthRef.current - 1) + if (dragDepthRef.current === 0) setDragOver(false) + } + + function onComposerDragOver(event: React.DragEvent) { + if (!localFilesEnabled) return + if (![...event.dataTransfer.types].includes("Files")) return + event.preventDefault() + event.dataTransfer.dropEffect = "copy" + } + + function onComposerDrop(event: React.DragEvent) { + if (!localFilesEnabled) return + event.preventDefault() + dragDepthRef.current = 0 + setDragOver(false) + const files = filesFromDataTransfer(event.dataTransfer) + void ingestLocalFiles(files, "drop") + } + + function onComposerPaste(event: React.ClipboardEvent) { + if (!localFilesEnabled) return + const files = filesFromClipboard(event.clipboardData) + if (files.length === 0) return + event.preventDefault() + void ingestLocalFiles(files, "paste") + } + + function pickCategory(kind: ReferenceKind | null) { + if (!pickerOpen || !mention) return + const edit = replaceMentionQuery(value, mention.range, "@") + const range = parseMentionQuery(edit.value, edit.caret) + if (!range) return + pendingCaretRef.current = edit + setMention({ + ...mention, + draft: edit.value, + range, + kind, + activeIndex: kind ? 0 : Math.max(0, REFERENCE_CATEGORIES.findIndex((category) => category.kind === mention.kind)), + }) + if (edit.value !== value) onChange(edit.value) + } + + async function pickReference(reference: NovaReference) { + if (!pickerOpen || !mention) return + + // Local workspace files → attach file contents (browser can't resolve them server-side). + if (isLocalFileReferenceId(reference.id)) { + if (!localFilesEnabled || !onLocalFilesChange) return + const path = localPathFromReferenceId(reference.id) + if (!path) return + setLocalFileBusy(true) + setLocalFileError(null) + try { + const read = await readLocalWorkspaceFile(path) + if (!read.ok) { + setLocalFileError(`${read.name}: ${read.error}`) + return + } + const merged = mergeLocalAttachments(localFiles, [read.attachment]) + onLocalFilesChange(merged.attachments) + if (merged.skipped.length) setLocalFileError(merged.skipped.slice(0, 2).join(" · ")) + setReferenceAnnouncement(`${reference.label} attached from local folder.`) + const edit = replaceMentionQuery(value, mention.range, "") + pendingCaretRef.current = edit + dismissMention() + onChange(edit.value) + } finally { + setLocalFileBusy(false) + textareaRef.current?.focus({ preventScroll: true }) + } + return + } + + if (!onReferencesChange) return + const result = addReference(references, reference) + if (result.status === "limit") return + if (result.status === "added") onReferencesChange(result.references) + setReferenceAnnouncement(`${reference.label} ${result.status === "added" ? "attached" : "is already attached"}.`) + const edit = replaceMentionQuery(value, mention.range, "") + pendingCaretRef.current = edit + dismissMention() + onChange(edit.value) + } + + async function handleOpenLocalFolder() { + if (!localFilesEnabled) return + setLocalFileBusy(true) + setLocalFileError(null) + try { + const state = await pickLocalWorkspaceFolder() + if (!state) return + search.refreshLocalWorkspace() + onLocalProjectChange?.(state) + setReferenceAnnouncement( + state.projectId + ? `Local project “${state.displayName}” saved · ${state.entries.length} files indexed and searchable.` + : `Local folder “${state.rootName}” linked · ${state.entries.length} files searchable.`, + ) + } catch (error) { + setLocalFileError(error instanceof Error ? error.message : "Could not open local folder") + } finally { + setLocalFileBusy(false) + textareaRef.current?.focus({ preventScroll: true }) + } + } + + return ( +
+ {dragOver ? ( +
+ Drop files, photos, or documents +
+ ) : null} +
+