Skip to content
Open
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
1 change: 1 addition & 0 deletions packages/db/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2336,6 +2336,7 @@ export const AppBuilderSessionReason = {
GitHubMigration: 'github_migration', // New session after migrating to GitHub
Upgrade: 'upgrade', // New session after worker version upgrade (v1→v2)
ModelVisionChange: 'model_vision_change', // New session after switching between vision and text-only models
UserInitiated: 'user_initiated', // New session explicitly started by the user via "New Chat"
} satisfies Record<string, string>;

export const app_builder_project_sessions = pgTable(
Expand Down
51 changes: 45 additions & 6 deletions src/components/app-builder/AppBuilderChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import React, {
useCallback,
useSyncExternalStore,
} from 'react';
import { User, ArrowDown, ChevronRight, ChevronDown } from 'lucide-react';
import { User, ArrowDown, ChevronRight, ChevronDown, SquarePen } from 'lucide-react';
import { format } from 'date-fns';
import { TimeAgo } from '@/components/shared/TimeAgo';
import AssistantLogo from '@/components/AssistantLogo';
Expand Down Expand Up @@ -449,7 +449,7 @@ function SessionMessages({
export function AppBuilderChat({ organizationId }: AppBuilderChatProps) {
// Get state and manager from ProjectSession context
const { manager, state } = useProject();
const { isStreaming, isInterrupting, model: projectModel, sessions } = state;
const { isStreaming, isInterrupting, model: projectModel, sessions, pendingNewSession } = state;

const messagesEndRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -636,12 +636,40 @@ export function AppBuilderChat({ organizationId }: AppBuilderChatProps) {
// Check if input should be disabled (no messages in any session yet)
const hasAnyMessages = activeSessionMessages.length > 0;

const handleNewChatToggle = useCallback(() => {
if (pendingNewSession) {
manager.cancelNewSession();
} else {
manager.requestNewSession();
}
}, [pendingNewSession, manager]);

return (
<div className="flex h-full flex-col">
{/* Header */}
<div className="flex h-12 items-center justify-between gap-4 border-b px-4">
<h2 className="shrink-0 text-sm font-medium">Chat</h2>
<FeedbackDialog disabled={isStreaming} organizationId={organizationId} />
<div className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
onClick={handleNewChatToggle}
disabled={isStreaming}
className={pendingNewSession ? 'text-primary bg-primary/10 h-8 w-8' : 'h-8 w-8'}
aria-label="New chat"
>
<SquarePen className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
{pendingNewSession ? 'Cancel new chat' : 'New chat'}
</TooltipContent>
</Tooltip>
<FeedbackDialog disabled={isStreaming} organizationId={organizationId} />
</div>
</div>

{/* Blocked Banner - show when user cannot use App Builder at all */}
Expand All @@ -663,7 +691,16 @@ export function AppBuilderChat({ organizationId }: AppBuilderChatProps) {
onScroll={handleScroll}
className="absolute inset-0 overflow-x-hidden overflow-y-auto p-4"
>
{sessions.length === 0 || (!hasAnyMessages && !isStreaming) ? (
{pendingNewSession ? (
<div className="flex h-full items-center justify-center">
<div className="text-center text-gray-400">
<p className="text-sm">New chat session</p>
<p className="mt-1 text-xs text-gray-500">
Your code is preserved — the AI starts with fresh context
</p>
</div>
</div>
) : sessions.length === 0 || (!hasAnyMessages && !isStreaming) ? (
<div className="flex h-full items-center justify-center">
<div className="text-center text-gray-400">
<p className="text-sm">Start building your app</p>
Expand Down Expand Up @@ -702,8 +739,10 @@ export function AppBuilderChat({ organizationId }: AppBuilderChatProps) {
onSubmit={handleSendMessage}
messageUuid={messageUuid}
organizationId={organizationId}
placeholder={isStreaming ? 'Building...' : 'Describe changes to your app...'}
disabled={!hasAnyMessages || isBlocked}
placeholder={
isStreaming ? 'Building...' : pendingNewSession ? 'What would you like to change?' : 'Describe changes to your app...'
}
disabled={(!hasAnyMessages && !pendingNewSession) || isBlocked}
isSubmitting={isStreaming}
onInterrupt={handleInterrupt}
isInterrupting={isInterrupting}
Expand Down
72 changes: 72 additions & 0 deletions src/components/app-builder/ProjectManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ export type ProjectManager = {
setGitRepoFullName: (repoFullName: string) => void;
deploy: () => Promise<DeployResult>;
destroy: () => void;
/** Enter "pending new session" mode — clears the chat area for a new message */
requestNewSession: () => void;
/** Cancel pending new session mode, returning to the current session view */
cancelNewSession: () => void;
};

export function createProjectManager(config: ProjectManagerConfig): ProjectManager {
Expand Down Expand Up @@ -316,6 +320,11 @@ export function createProjectManager(config: ProjectManagerConfig): ProjectManag
}

function sendMessage(message: string, images?: Images, model?: string): void {
if (store.getState().pendingNewSession) {
sendMessageAsNewSession(message, images, model);
return;
}

const activeSession = getActiveSession();
if (!activeSession) {
logger.logWarn('Cannot send message: no active session');
Expand All @@ -330,6 +339,57 @@ export function createProjectManager(config: ProjectManagerConfig): ProjectManag
void activeSession.sendMessage(message, images, effectiveModel);
}

/**
* Sends the first message of a user-initiated new session.
* Calls sendMessage tRPC mutation with forceNewSession:true, then delegates
* to handleSessionChanged to create the new session object and begin streaming.
*/
function sendMessageAsNewSession(message: string, images?: Images, model?: string): void {
if (destroyed) {
logger.logWarn('Cannot start new session: ProjectManager is destroyed');
return;
}

if (model) {
store.setState({ model });
}

const effectiveModel = model ?? store.getState().model;

store.setState({ pendingNewSession: false, isStreaming: true });

const mutationPromise = organizationId
? trpcClient.organizations.appBuilder.sendMessage.mutate({
projectId,
organizationId,
message,
images,
model: effectiveModel,
forceNewSession: true,
})
: trpcClient.appBuilder.sendMessage.mutate({
projectId,
message,
images,
model: effectiveModel,
forceNewSession: true,
});

void mutationPromise
.then(result => {
if (destroyed) return;
handleSessionChanged(result.cloudAgentSessionId, result.workerVersion, {
text: message,
images,
});
})
.catch((err: Error) => {
if (destroyed) return;
logger.logError('Failed to start new session', err);
store.setState({ isStreaming: false });
});
}

function interrupt(): void {
const activeSession = getActiveSession();
if (!activeSession) return;
Expand Down Expand Up @@ -373,6 +433,16 @@ export function createProjectManager(config: ProjectManagerConfig): ProjectManag
return deployProject({ projectId, organizationId, trpcClient, store });
}

function requestNewSession(): void {
if (destroyed) return;
store.setState({ pendingNewSession: true });
}

function cancelNewSession(): void {
if (destroyed) return;
store.setState({ pendingNewSession: false });
}

function destroy(): void {
if (destroyed) return;
destroyed = true;
Expand Down Expand Up @@ -408,5 +478,7 @@ export function createProjectManager(config: ProjectManagerConfig): ProjectManag
setGitRepoFullName,
deploy,
destroy,
requestNewSession,
cancelNewSession,
};
}
1 change: 1 addition & 0 deletions src/components/app-builder/project-manager/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export function createInitialState(
currentIframeUrl: null,
gitRepoFullName,
sessions: [],
pendingNewSession: false,
};
}

Expand Down
2 changes: 2 additions & 0 deletions src/components/app-builder/project-manager/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ export type ProjectState = {
gitRepoFullName: string | null;
/** Session objects — each owns its own messages and streaming state */
sessions: AppBuilderSession[];
/** True while the user has clicked "New Chat" but hasn't sent the first message yet */
pendingNewSession: boolean;
};

export type StateListener = () => void;
Expand Down
32 changes: 30 additions & 2 deletions src/lib/app-builder/app-builder-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,13 +255,14 @@ type CreateSessionParams = {
authToken: string;
gitRepoFullName: string | null;
images?: Images;
reason: 'upgrade' | 'github_migration' | 'model_vision_change';
reason: 'upgrade' | 'github_migration' | 'model_vision_change' | 'user_initiated';
};

function toSessionReason(reason: CreateSessionParams['reason']): string {
if (reason === 'upgrade') return AppBuilderSessionReason.Upgrade;
if (reason === 'github_migration') return AppBuilderSessionReason.GitHubMigration;
if (reason === 'model_vision_change') return AppBuilderSessionReason.ModelVisionChange;
if (reason === 'user_initiated') return AppBuilderSessionReason.UserInitiated;
reason satisfies never;
throw new Error(`Unhandled session reason: ${reason}`);
}
Expand Down Expand Up @@ -989,7 +990,7 @@ export async function startSessionForProject(
}

export async function sendMessage(input: SendMessageInput): Promise<SendMessageResult> {
const { projectId, owner, message, authToken, images, model } = input;
const { projectId, owner, message, authToken, images, model, forceNewSession } = input;

const project = await getProjectWithOwnershipCheck(projectId, owner);

Expand All @@ -1014,6 +1015,33 @@ export async function sendMessage(input: SendMessageInput): Promise<SendMessageR
const userId = project.created_by_user_id ?? owner.id;
const requiredWorkerVersion = await getRequiredWorkerVersion(userId);

// When forceNewSession is true, bypass the automatic session-change logic and
// create a new session immediately with reason 'user_initiated'.
if (forceNewSession) {
const createParams = {
projectId,
currentSessionId,
createdByUserId: project.created_by_user_id ?? owner.id,
owner,
message,
model: effectiveModel,
authToken,
gitRepoFullName: project.git_repo_full_name,
images,
reason: 'user_initiated' as const,
} satisfies CreateSessionParams;

const result =
requiredWorkerVersion === 'v2'
? await createV2Session(createParams)
: await createV1Session(createParams);

return {
cloudAgentSessionId: result.cloudAgentSessionId,
workerVersion: requiredWorkerVersion,
};
}

const decision = await shouldCreateNewSession(
project,
currentSessionId,
Expand Down
2 changes: 2 additions & 0 deletions src/lib/app-builder/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ export type SendMessageInput = {
images?: Images;
/** Optional model override - if provided, updates the project's model_id */
model?: string;
/** When true, forces creation of a new cloud agent session (user-initiated new chat) */
forceNewSession?: boolean;
};

/**
Expand Down
1 change: 1 addition & 0 deletions src/routers/app-builder-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ export const appBuilderRouter = createTRPCRouter({
authToken,
images: input.images,
model: input.model,
forceNewSession: input.forceNewSession,
});

return {
Expand Down
2 changes: 2 additions & 0 deletions src/routers/app-builder/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ export const sendMessageBaseSchema = z.object({
images: imagesSchema,
/** Optional model override - if provided, updates the project's model_id */
model: z.string().min(1).optional(),
/** When true, forces creation of a new cloud agent session (user-initiated new chat) */
forceNewSession: z.boolean().optional(),
});

// Common extension for organizationId
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,7 @@ export const organizationAppBuilderRouter = createTRPCRouter({
authToken,
images: input.images,
model: input.model,
forceNewSession: input.forceNewSession,
});

return {
Expand Down
Loading