Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 0 additions & 94 deletions app/api/ai/design/route.ts

This file was deleted.

11 changes: 8 additions & 3 deletions app/api/projects/[projectId]/ai-sessions/[sessionId]/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { auth } from "@clerk/nextjs/server";

import { deleteSession, getSession } from "@/lib/ai/session-store";
import { deleteSession } from "@/lib/ai/session-store";
import { getSettledSession } from "@/lib/ai/session-turns";
import { getAccessibleProject, getCurrentIdentity } from "@/lib/project-access";

type SessionRouteContext = { params: Promise<{ projectId: string; sessionId: string }> };
Expand Down Expand Up @@ -34,15 +35,19 @@ async function resolveScope(projectId: string) {
return { scope: { projectId: project.id, userId } } as const;
}

/** Returns one of the current user's sessions with its full transcript. */
/**
* Returns one of the current user's sessions with its full transcript. Turns
* whose runs have finished are settled first, so the reply is there even if the
* tab that sent the message was closed.
*/
export async function GET(_request: Request, context: SessionRouteContext) {
const { projectId, sessionId } = await context.params;
const result = await resolveScope(projectId);
if ("error" in result) {
return result.error;
}

const session = await getSession(result.scope, sessionId);
const session = await getSettledSession(result.scope, sessionId);
if (!session) {
return notFoundResponse();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { auth } from "@clerk/nextjs/server";

import { MAX_MESSAGE_LENGTH } from "@/lib/ai/session-limits";
import { startTurn } from "@/lib/ai/session-turns";
import { getAccessibleProject, getCurrentIdentity } from "@/lib/project-access";

type TurnRouteContext = { params: Promise<{ projectId: string; sessionId: string }> };

/** Only `{ type: "message", text }` for now; answers/generate/skip arrive with the turn engine. */
function parseMessageText(value: unknown): string | null {
if (typeof value !== "object" || value === null) {
return null;
}

const candidate = value as Record<string, unknown>;
if (candidate.type !== "message" || typeof candidate.text !== "string") {
return null;
}

const text = candidate.text.trim();
if (text.length === 0 || text.length > MAX_MESSAGE_LENGTH) {
return null;
}

return text;
}

/**
* Sends a message in one of the current user's sessions and starts the design
* run that answers it. Responds 202 with the updated session (the reply is a
* PENDING message carrying the run id), or 502 with the session when the run
* could not be started.
*/
export async function POST(request: Request, context: TurnRouteContext) {
const { userId } = await auth();
if (!userId) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}

let body: unknown;
try {
body = await request.json();
} catch {
return Response.json({ error: "Invalid request body" }, { status: 400 });
}

const text = parseMessageText(body);
if (!text) {
return Response.json(
{ error: `Message text is required and must be at most ${MAX_MESSAGE_LENGTH} characters` },
{ status: 400 },
);
}

const { projectId, sessionId } = await context.params;
const { primaryEmail } = await getCurrentIdentity();
const project = await getAccessibleProject(userId, primaryEmail, projectId);
if (!project) {
return Response.json({ error: "Forbidden" }, { status: 403 });
}

const result = await startTurn({ projectId: project.id, userId }, sessionId, text);
if (result.ok) {
return Response.json({ session: result.session }, { status: 202 });
}

return Response.json(
{ error: result.error, ...(result.session ? { session: result.session } : {}) },
{ status: result.status },
);
}
97 changes: 97 additions & 0 deletions components/editor/ai-session-history.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
"use client";

import { useState } from "react";
import { Trash2 } from "lucide-react";

import { AI_SESSION_TTL_DAYS, MAX_SESSIONS_PER_USER_PROJECT } from "@/lib/ai/session-limits";
import { formatRelativeTime } from "@/lib/relative-time";
import { cn } from "@/lib/utils";
import type { AiSessionSummary } from "@/types/ai-session";

interface AiSessionHistoryProps {
sessions: AiSessionSummary[];
activeSessionId: string | null;
/** False while a message is being sent. */
canSwitch: boolean;
onSelect: (sessionId: string) => void;
onDelete: (sessionId: string) => void;
}

const focusClass = "outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink/60";

const DAY_MS = 24 * 60 * 60 * 1000;

function describeExpiry(expiresAt: string, now: number): string {
const remaining = new Date(expiresAt).getTime() - now;
if (remaining < DAY_MS) {
return "expires today";
}
return `expires in ${Math.floor(remaining / DAY_MS)}d`;
}

/** The AI Architect's saved chats, shown in place of the transcript. */
export function AiSessionHistory({ sessions, activeSessionId, canSwitch, onSelect, onDelete }: AiSessionHistoryProps) {
// Captured when the list opens; relative times don't need to tick while it is visible.
const [now] = useState(() => Date.now());

return (
<div className="space-y-3">
<div className="flex items-center justify-between font-mono text-chrome tracking-chrome text-ink-soft uppercase">
<span>Recent chats</span>
<span>
{sessions.length}/{MAX_SESSIONS_PER_USER_PROJECT}
</span>
</div>

{sessions.length === 0 ? (
<p className="rounded-paper border border-dashed border-ink/25 px-3.5 py-4 font-brand text-sm text-ink-soft">
No saved chats yet. Send a message to start one.
</p>
) : (
<ul className="space-y-2">
{sessions.map((entry) => {
const isActive = entry.id === activeSessionId;

return (
<li key={entry.id} className="relative">
<button
type="button"
onClick={() => onSelect(entry.id)}
disabled={!canSwitch}
aria-current={isActive ? "true" : undefined}
className={cn(
"block w-full cursor-pointer rounded-paper border bg-paper-bright py-2.5 pr-11 pl-3.5 text-left transition-colors",
"disabled:cursor-not-allowed disabled:opacity-60",
isActive ? "border-ink" : "border-ink/20 hover:border-ink",
focusClass,
)}
>
<span className="block truncate font-brand text-sm font-medium text-ink">{entry.title}</span>
<span className="mt-0.5 block font-mono text-chrome tracking-chrome text-ink-soft uppercase">
{formatRelativeTime(entry.lastActivityAt, now)} · {describeExpiry(entry.expiresAt, now)}
</span>
</button>
<button
type="button"
onClick={() => onDelete(entry.id)}
aria-label={`Delete chat: ${entry.title}`}
className={cn(
"absolute top-1/2 right-2 flex size-8 -translate-y-1/2 cursor-pointer items-center justify-center rounded-paper text-ink-soft",
"transition-colors hover:bg-paper-pin-red/10 hover:text-paper-pin-red",
focusClass,
)}
>
<Trash2 className="h-4 w-4" aria-hidden="true" />
</button>
</li>
);
})}
</ul>
)}

<p className="font-mono text-chrome tracking-chrome text-ink-soft uppercase">
Chats are kept for {AI_SESSION_TTL_DAYS} days after last use
</p>
</div>
);
}
Loading
Loading