From 6d311f40b48c5cf63fe1ab4a9aa18434e74a8c2c Mon Sep 17 00:00:00 2001 From: Pablo P Varela Date: Fri, 12 Jun 2026 18:03:04 +0200 Subject: [PATCH 1/2] refactor: split god files per AGENTS.md conventions (#67) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split three large files into focused modules following the queue.model.ts barrel-reexport pattern: - src/actions/projects.ts (966→33 lines): 10 focused action modules (create, crud, delete, lifecycle, production, settings, prompt, queue-status, production-history, opencode) - src/hooks/useChatPanel.ts (1136→281 lines): 6 focused hooks (scroll, sse, restore, send, actions, history) - src/stores/useChatStore.ts (648→379 lines): 4 handler modules (messageHandlers, sessionHandlers, interactionHandlers, toolHandlers) Pure refactor — zero behavior changes. All barrel re-exports preserve backward compatibility for astro:actions callers and hook imports. --- src/actions/projects.create.ts | 70 ++ src/actions/projects.crud.ts | 67 ++ src/actions/projects.delete.ts | 67 ++ src/actions/projects.lifecycle.ts | 83 ++ src/actions/projects.opencode.ts | 48 + src/actions/projects.production-history.ts | 66 ++ src/actions/projects.production.ts | 251 ++++ src/actions/projects.prompt.ts | 59 + src/actions/projects.queue-status.ts | 218 ++++ src/actions/projects.settings.ts | 77 ++ src/actions/projects.ts | 987 +--------------- src/hooks/useChatActions.ts | 268 +++++ src/hooks/useChatHistory.ts | 516 ++++++++ src/hooks/useChatPanel.ts | 1037 ++--------------- src/hooks/useChatRestore.ts | 134 +++ src/hooks/useChatScroll.ts | 40 + src/hooks/useChatSend.ts | 163 +++ src/hooks/useChatSse.ts | 143 +++ .../useChatStore.interactionHandlers.ts | 42 + src/stores/useChatStore.messageHandlers.ts | 242 ++++ src/stores/useChatStore.sessionHandlers.ts | 33 + src/stores/useChatStore.toolHandlers.ts | 78 ++ src/stores/useChatStore.ts | 341 +----- 23 files changed, 2819 insertions(+), 2211 deletions(-) create mode 100644 src/actions/projects.create.ts create mode 100644 src/actions/projects.crud.ts create mode 100644 src/actions/projects.delete.ts create mode 100644 src/actions/projects.lifecycle.ts create mode 100644 src/actions/projects.opencode.ts create mode 100644 src/actions/projects.production-history.ts create mode 100644 src/actions/projects.production.ts create mode 100644 src/actions/projects.prompt.ts create mode 100644 src/actions/projects.queue-status.ts create mode 100644 src/actions/projects.settings.ts create mode 100644 src/hooks/useChatActions.ts create mode 100644 src/hooks/useChatHistory.ts create mode 100644 src/hooks/useChatRestore.ts create mode 100644 src/hooks/useChatScroll.ts create mode 100644 src/hooks/useChatSend.ts create mode 100644 src/hooks/useChatSse.ts create mode 100644 src/stores/useChatStore.interactionHandlers.ts create mode 100644 src/stores/useChatStore.messageHandlers.ts create mode 100644 src/stores/useChatStore.sessionHandlers.ts create mode 100644 src/stores/useChatStore.toolHandlers.ts diff --git a/src/actions/projects.create.ts b/src/actions/projects.create.ts new file mode 100644 index 0000000..0bc1970 --- /dev/null +++ b/src/actions/projects.create.ts @@ -0,0 +1,70 @@ +import { ActionError, defineAction } from "astro:actions"; +import { randomBytes } from "node:crypto"; +import { z } from "astro/zod"; +import { logger } from "@/server/logger"; +import { getAvailableModels } from "@/server/opencode/models"; +import { enqueueProjectCreate } from "@/server/queue/enqueue"; + +export const create = defineAction({ + accept: "json", + input: z.object({ + prompt: z.string().min(1, "Please describe your website"), + model: z.string().optional(), + attachments: z.string().optional(), + }), + handler: async (input, context) => { + const user = context.locals.user; + if (!user) { + throw new ActionError({ + code: "UNAUTHORIZED", + message: "You must be logged in to create a project", + }); + } + + const availableModels = await getAvailableModels(); + if (availableModels.length === 0) { + throw new ActionError({ + code: "BAD_REQUEST", + message: "No models available. Please check your provider settings.", + }); + } + + let attachments: + | Array<{ + filename: string; + mime: string; + dataUrl: string; + kind?: "image" | "text"; + textContent?: string; + }> + | undefined; + if (input.attachments) { + try { + attachments = JSON.parse(input.attachments); + } catch {} + } + + const projectId = randomBytes(12).toString("hex"); + + try { + await enqueueProjectCreate({ + projectId, + ownerUserId: user.id, + prompt: input.prompt, + model: input.model ?? null, + attachments, + }); + } catch (err) { + logger.error({ err }, "Failed to enqueue project creation"); + throw new ActionError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to start project creation", + }); + } + + return { + success: true, + projectId, + }; + }, +}); diff --git a/src/actions/projects.crud.ts b/src/actions/projects.crud.ts new file mode 100644 index 0000000..828009b --- /dev/null +++ b/src/actions/projects.crud.ts @@ -0,0 +1,67 @@ +import { ActionError, defineAction } from "astro:actions"; +import { z } from "astro/zod"; +import { + getProjectById, + getProjectsByUserId, +} from "@/server/projects/projects.model"; + +export const list = defineAction({ + handler: async (_input, context) => { + const user = context.locals.user; + if (!user) { + throw new ActionError({ + code: "UNAUTHORIZED", + message: "You must be logged in to list projects", + }); + } + + const { getProjectRuntimeUrls } = await import( + "@/server/projects/projectUrls" + ); + const projects = await getProjectsByUserId(user.id); + const projectsWithUrls = await Promise.all( + projects.map(async (project) => { + const urls = await getProjectRuntimeUrls(project); + return { + ...project, + previewUrl: urls.preview.preferred, + previewUrls: urls.preview, + productionUrls: urls.production, + }; + }), + ); + return { projects: projectsWithUrls }; + }, +}); + +export const get = defineAction({ + input: z.object({ + projectId: z.string(), + }), + handler: async (input, context) => { + const user = context.locals.user; + if (!user) { + throw new ActionError({ + code: "UNAUTHORIZED", + message: "You must be logged in to view a project", + }); + } + + const project = await getProjectById(input.projectId); + if (!project) { + throw new ActionError({ + code: "NOT_FOUND", + message: "Project not found", + }); + } + + if (project.ownerUserId !== user.id) { + throw new ActionError({ + code: "FORBIDDEN", + message: "You don't have access to this project", + }); + } + + return { project }; + }, +}); diff --git a/src/actions/projects.delete.ts b/src/actions/projects.delete.ts new file mode 100644 index 0000000..0426b84 --- /dev/null +++ b/src/actions/projects.delete.ts @@ -0,0 +1,67 @@ +import { ActionError, defineAction } from "astro:actions"; +import { z } from "astro/zod"; +import { + isProjectOwnedByUser, + updateProjectStatus, +} from "@/server/projects/projects.model"; +import { + enqueueDeleteAllProjectsForUser, + enqueueProjectDelete, +} from "@/server/queue/enqueue"; + +export const deleteProject = defineAction({ + input: z.object({ + projectId: z.string(), + }), + handler: async (input, context) => { + const user = context.locals.user; + if (!user) { + throw new ActionError({ + code: "UNAUTHORIZED", + message: "You must be logged in to delete a project", + }); + } + + const isOwner = await isProjectOwnedByUser(input.projectId, user.id); + if (!isOwner) { + throw new ActionError({ + code: "FORBIDDEN", + message: "You don't have access to this project", + }); + } + + try { + await updateProjectStatus(input.projectId, "deleting"); + } catch {} + + try { + const { cancelActiveProductionJobs } = await import( + "@/server/productions/productions.model" + ); + await cancelActiveProductionJobs(input.projectId); + } catch {} + + const job = await enqueueProjectDelete({ + projectId: input.projectId, + requestedByUserId: user.id, + }); + + return { success: true, jobId: job.id }; + }, +}); + +export const deleteAll = defineAction({ + handler: async (_input, context) => { + const user = context.locals.user; + if (!user) { + throw new ActionError({ + code: "UNAUTHORIZED", + message: "You must be logged in to delete projects", + }); + } + + const job = await enqueueDeleteAllProjectsForUser({ userId: user.id }); + + return { success: true, jobId: job.id }; + }, +}); diff --git a/src/actions/projects.lifecycle.ts b/src/actions/projects.lifecycle.ts new file mode 100644 index 0000000..9fc6d99 --- /dev/null +++ b/src/actions/projects.lifecycle.ts @@ -0,0 +1,83 @@ +import { ActionError, defineAction } from "astro:actions"; +import { z } from "astro/zod"; +import { isProjectOwnedByUser } from "@/server/projects/projects.model"; +import { enqueueDockerStop } from "@/server/queue/enqueue"; + +export const stop = defineAction({ + input: z.object({ + projectId: z.string(), + }), + handler: async (input, context) => { + const user = context.locals.user; + if (!user) { + throw new ActionError({ + code: "UNAUTHORIZED", + message: "You must be logged in to stop a project", + }); + } + + const isOwner = await isProjectOwnedByUser(input.projectId, user.id); + if (!isOwner) { + throw new ActionError({ + code: "FORBIDDEN", + message: "You don't have access to this project", + }); + } + + const job = await enqueueDockerStop({ + projectId: input.projectId, + reason: "user", + }); + + return { success: true, jobId: job.id }; + }, +}); + +export const restart = defineAction({ + input: z.object({ + projectId: z.string(), + }), + handler: async (input, context) => { + const user = context.locals.user; + if (!user) { + throw new ActionError({ + code: "UNAUTHORIZED", + message: "You must be logged in to restart a project", + }); + } + + const isOwner = await isProjectOwnedByUser(input.projectId, user.id); + if (!isOwner) { + throw new ActionError({ + code: "FORBIDDEN", + message: "You don't have access to this project", + }); + } + + const { getProjectById } = await import("@/server/projects/projects.model"); + const project = await getProjectById(input.projectId); + if (!project) { + throw new ActionError({ + code: "NOT_FOUND", + message: "Project not found", + }); + } + + const { enqueueDockerEnsureRunning } = await import( + "@/server/queue/enqueue" + ); + + const { updateProjectStatus } = await import( + "@/server/projects/projects.model" + ); + + await updateProjectStatus(input.projectId, "starting"); + + const job = await enqueueDockerEnsureRunning({ + projectId: input.projectId, + reason: "user", + }); + + return { success: true, jobId: job.id }; + }, +}); diff --git a/src/actions/projects.opencode.ts b/src/actions/projects.opencode.ts new file mode 100644 index 0000000..07de2eb --- /dev/null +++ b/src/actions/projects.opencode.ts @@ -0,0 +1,48 @@ +import { ActionError, defineAction } from "astro:actions"; +import { z } from "astro/zod"; +import { logger } from "@/server/logger"; +import { restartGlobalOpencode } from "@/server/opencode/runtime"; +import { isProjectOwnedByUser } from "@/server/projects/projects.model"; + +export const restartOpencode = defineAction({ + accept: "json", + input: z.object({ + projectId: z.string(), + }), + handler: async (input, context) => { + const user = context.locals.user; + if (!user) { + throw new ActionError({ + code: "UNAUTHORIZED", + message: "You must be logged in to restart the agent", + }); + } + + const isOwner = await isProjectOwnedByUser(input.projectId, user.id); + if (!isOwner) { + throw new ActionError({ + code: "NOT_FOUND", + message: "Project not found", + }); + } + + try { + logger.info( + { projectId: input.projectId, userId: user.id }, + "Restarting OpenCode agent", + ); + await restartGlobalOpencode(); + return { success: true }; + } catch (error) { + logger.error( + { error, projectId: input.projectId }, + "Failed to restart OpenCode agent", + ); + throw new ActionError({ + code: "INTERNAL_SERVER_ERROR", + message: + error instanceof Error ? error.message : "Failed to restart agent", + }); + } + }, +}); diff --git a/src/actions/projects.production-history.ts b/src/actions/projects.production-history.ts new file mode 100644 index 0000000..ac90913 --- /dev/null +++ b/src/actions/projects.production-history.ts @@ -0,0 +1,66 @@ +import { ActionError, defineAction } from "astro:actions"; +import { z } from "astro/zod"; +import { + getProjectById, + isProjectOwnedByUser, +} from "@/server/projects/projects.model"; + +export const getProductionHistory = defineAction({ + input: z.object({ + projectId: z.string(), + }), + handler: async (input, context) => { + const user = context.locals.user; + if (!user) { + throw new ActionError({ + code: "UNAUTHORIZED", + message: "You must be logged in", + }); + } + + const isOwner = await isProjectOwnedByUser(input.projectId, user.id); + if (!isOwner) { + throw new ActionError({ + code: "FORBIDDEN", + message: "You don't have access to this project", + }); + } + + const project = await getProjectById(input.projectId); + if (!project) { + throw new ActionError({ + code: "NOT_FOUND", + message: "Project not found", + }); + } + + const { getProductionVersions } = await import( + "@/server/productions/cleanup" + ); + + const { getProjectRuntimeUrls } = await import( + "@/server/projects/projectUrls" + ); + const versions = await getProductionVersions(input.projectId); + const productionPort = project.productionPort; + const urls = await getProjectRuntimeUrls(project); + + return { + productionPort, + baseUrl: urls.production.preferred, + versions: versions.map((v) => { + return { + hash: v.hash, + isActive: v.isActive, + createdAt: v.mtimeIso, + url: v.isActive + ? (urls.production.preferred ?? undefined) + : undefined, + baseUrl: urls.production.local, + previewUrl: urls.production.tailscale ?? undefined, + productionPort, + }; + }), + }; + }, +}); diff --git a/src/actions/projects.production.ts b/src/actions/projects.production.ts new file mode 100644 index 0000000..da31d53 --- /dev/null +++ b/src/actions/projects.production.ts @@ -0,0 +1,251 @@ +import { ActionError, defineAction } from "astro:actions"; +import { z } from "astro/zod"; +import { + getProjectById, + isProjectOwnedByUser, +} from "@/server/projects/projects.model"; +import { + enqueueProductionBuild, + enqueueProductionStop, +} from "@/server/queue/enqueue"; + +export const deploy = defineAction({ + input: z.object({ + projectId: z.string(), + }), + handler: async (input, context) => { + const user = context.locals.user; + if (!user) { + throw new ActionError({ + code: "UNAUTHORIZED", + message: "You must be logged in to deploy a project", + }); + } + + const isOwner = await isProjectOwnedByUser(input.projectId, user.id); + if (!isOwner) { + throw new ActionError({ + code: "FORBIDDEN", + message: "You don't have access to this project", + }); + } + + const project = await getProjectById(input.projectId); + if (!project) { + throw new ActionError({ + code: "NOT_FOUND", + message: "Project not found", + }); + } + + if (project.status !== "running") { + throw new ActionError({ + code: "BAD_REQUEST", + message: `Cannot deploy project while it's ${project.status}`, + }); + } + + const { hasActiveDeployment, updateProductionStatus } = await import( + "@/server/productions/productions.model" + ); + if (await hasActiveDeployment(input.projectId)) { + throw new ActionError({ + code: "CONFLICT", + message: "A deployment is already in progress", + }); + } + + await updateProductionStatus(input.projectId, "queued", { + productionError: null, + }); + + const job = await enqueueProductionBuild({ + projectId: input.projectId, + }); + + return { success: true, jobId: job.id }; + }, +}); + +export const stopProduction = defineAction({ + input: z.object({ + projectId: z.string(), + }), + handler: async (input, context) => { + const user = context.locals.user; + if (!user) { + throw new ActionError({ + code: "UNAUTHORIZED", + message: "You must be logged in to stop production", + }); + } + + const isOwner = await isProjectOwnedByUser(input.projectId, user.id); + if (!isOwner) { + throw new ActionError({ + code: "FORBIDDEN", + message: "You don't have access to this project", + }); + } + + const project = await getProjectById(input.projectId); + if (!project) { + throw new ActionError({ + code: "NOT_FOUND", + message: "Project not found", + }); + } + + const { cancelActiveProductionJobs } = await import( + "@/server/productions/productions.model" + ); + await cancelActiveProductionJobs(input.projectId); + + const job = await enqueueProductionStop(input.projectId); + + return { success: true, jobId: job.id }; + }, +}); + +export const rollback = defineAction({ + input: z.object({ + projectId: z.string(), + toHash: z.string(), + }), + handler: async (input, context) => { + const user = context.locals.user; + if (!user) { + throw new ActionError({ + code: "UNAUTHORIZED", + message: "You must be logged in", + }); + } + + const isOwner = await isProjectOwnedByUser(input.projectId, user.id); + if (!isOwner) { + throw new ActionError({ + code: "FORBIDDEN", + message: "You don't have access to this project", + }); + } + + const project = await getProjectById(input.projectId); + if (!project) { + throw new ActionError({ + code: "NOT_FOUND", + message: "Project not found", + }); + } + + const { getProductionVersions } = await import( + "@/server/productions/cleanup" + ); + const { getProductionPath } = await import("@/server/projects/paths"); + const { updateProductionStatus } = await import( + "@/server/productions/productions.model" + ); + + const versions = await getProductionVersions(input.projectId); + const targetVersion = versions.find((v) => v.hash === input.toHash); + + if (!targetVersion) { + throw new ActionError({ + code: "NOT_FOUND", + message: "Target version not found", + }); + } + + if (targetVersion.isActive) { + throw new ActionError({ + code: "BAD_REQUEST", + message: "Target version is already active", + }); + } + + const productionPort = project.productionPort; + if (!productionPort) { + throw new ActionError({ + code: "BAD_REQUEST", + message: "Project not initialized for production", + }); + } + + // Build Docker image for rollback version + const productionPath = getProductionPath(project.id, input.toHash); + const { getProductionContainerName, getProductionImageName } = await import( + "@/server/projects/paths" + ); + const imageName = getProductionImageName(project.id, input.toHash); + + const { spawnCommand } = await import("@/server/utils/execAsync"); + const buildResult = await spawnCommand( + "docker", + ["build", "-t", imageName, "-f", "Dockerfile.prod", "."], + { cwd: productionPath }, + ); + + if (!buildResult.success) { + throw new ActionError({ + code: "INTERNAL_SERVER_ERROR", + message: `Docker build failed: ${buildResult.stderr.slice(0, 200)}`, + }); + } + + // Stop and remove old container + const containerName = getProductionContainerName(project.id); + const stopResult = await spawnCommand("docker", ["stop", containerName]); + const removeResult = await spawnCommand("docker", ["rm", containerName]); + + if (!stopResult.success || !removeResult.success) { + // Container might not exist, continue + } + + // Start new container + const runResult = await spawnCommand("docker", [ + "run", + "-d", + "--name", + containerName, + "-p", + `${productionPort}:3000`, + "--restart", + "unless-stopped", + imageName, + ]); + + if (!runResult.success) { + throw new ActionError({ + code: "INTERNAL_SERVER_ERROR", + message: `Docker run failed: ${runResult.stderr.slice(0, 200)}`, + }); + } + + // Update symlink + const { getProductionCurrentSymlink } = await import( + "@/server/projects/paths" + ); + const symlinkPath = getProductionCurrentSymlink(project.id); + const hashPath = getProductionPath(project.id, input.toHash); + const tempSymlink = `${symlinkPath}.tmp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + const fs = await import("node:fs/promises"); + try { + await fs.unlink(tempSymlink).catch(() => {}); + await fs.symlink(hashPath, tempSymlink); + await fs.rename(tempSymlink, symlinkPath); + } catch { + // Symlink update failed, but container is running + } + + const { getCanonicalProductionUrl } = await import( + "@/server/productions/productionUrl" + ); + await updateProductionStatus(input.projectId, "running", { + productionHash: input.toHash, + productionStartedAt: new Date(), + productionUrl: await getCanonicalProductionUrl(project), + }); + + return { success: true }; + }, +}); diff --git a/src/actions/projects.prompt.ts b/src/actions/projects.prompt.ts new file mode 100644 index 0000000..b605bf5 --- /dev/null +++ b/src/actions/projects.prompt.ts @@ -0,0 +1,59 @@ +import { ActionError, defineAction } from "astro:actions"; +import { z } from "astro/zod"; +import { + isProjectOwnedByUser, + markInitialPromptCompleted as markInitialPromptCompletedModel, + markInitialPromptSent as markInitialPromptSentModel, +} from "@/server/projects/projects.model"; + +export const markInitialPromptSent = defineAction({ + input: z.object({ + projectId: z.string(), + }), + handler: async (input, context) => { + const user = context.locals.user; + if (!user) { + throw new ActionError({ + code: "UNAUTHORIZED", + message: "You must be logged in", + }); + } + + const isOwner = await isProjectOwnedByUser(input.projectId, user.id); + if (!isOwner) { + throw new ActionError({ + code: "FORBIDDEN", + message: "You don't have access to this project", + }); + } + + await markInitialPromptSentModel(input.projectId); + return { success: true }; + }, +}); + +export const markInitialPromptCompleted = defineAction({ + input: z.object({ + projectId: z.string(), + }), + handler: async (input, context) => { + const user = context.locals.user; + if (!user) { + throw new ActionError({ + code: "UNAUTHORIZED", + message: "You must be logged in", + }); + } + + const isOwner = await isProjectOwnedByUser(input.projectId, user.id); + if (!isOwner) { + throw new ActionError({ + code: "FORBIDDEN", + message: "You don't have access to this project", + }); + } + + await markInitialPromptCompletedModel(input.projectId); + return { success: true }; + }, +}); diff --git a/src/actions/projects.queue-status.ts b/src/actions/projects.queue-status.ts new file mode 100644 index 0000000..5d14ef8 --- /dev/null +++ b/src/actions/projects.queue-status.ts @@ -0,0 +1,218 @@ +import { ActionError, defineAction } from "astro:actions"; +import { z } from "astro/zod"; +import { + getProjectById, + isProjectOwnedByUser, + markUserPromptCompleted, +} from "@/server/projects/projects.model"; +import { + getQueueJobDerivedError, + getQueueJobDerivedState, +} from "@/server/queue/job-state"; + +export const getQueueStatus = defineAction({ + input: z.object({ + projectId: z.string(), + }), + handler: async (input, context) => { + const user = context.locals.user; + if (!user) { + throw new ActionError({ + code: "UNAUTHORIZED", + message: "You must be logged in", + }); + } + + const isOwner = await isProjectOwnedByUser(input.projectId, user.id); + if (!isOwner) { + throw new ActionError({ + code: "NOT_FOUND", + message: "Project not found", + }); + } + + const project = await getProjectById(input.projectId); + if (!project) { + throw new ActionError({ + code: "NOT_FOUND", + message: "Project not found", + }); + } + + const { listJobs } = await import("@/server/queue/queue.model"); + + const SETUP_JOBS = [ + "project.create", + "docker.composeUp", + "docker.ensureRunning", + "docker.waitReady", + "opencode.sessionCreate", + "opencode.sendUserPrompt", + ] as const; + + const LEGACY_SEND_PROMPT_JOB = "opencode.sendInitialPrompt"; + const JOB_TIMEOUT_MS = 5 * 60 * 1000; + + const jobs = await listJobs({ + projectId: input.projectId, + limit: 100, + }); + + type SetupJob = { + type: string; + state: "pending" | (typeof jobs)[number]["state"] | "exhausted"; + error?: string; + completedAt?: number; + createdAt?: number; + }; + + const jobsByType = new Map(); + for (const job of jobs) { + if ( + SETUP_JOBS.includes(job.type as (typeof SETUP_JOBS)[number]) && + !jobsByType.has(job.type) + ) { + jobsByType.set(job.type, job); + } + if ( + job.type === LEGACY_SEND_PROMPT_JOB && + !jobsByType.has("opencode.sendUserPrompt") + ) { + jobsByType.set("opencode.sendUserPrompt", job); + } + } + + const setupJobs: Record = {}; + let hasError = false; + let errorMessage: string | undefined; + let promptSentAt: number | undefined; + let isSetupComplete = true; + + const projectCreateJob = jobsByType.get("project.create"); + const dockerComposeUpJob = jobsByType.get("docker.composeUp"); + const dockerEnsureRunningJob = jobsByType.get("docker.ensureRunning"); + const dockerWaitReadyJob = jobsByType.get("docker.waitReady"); + const sessionCreateJob = jobsByType.get("opencode.sessionCreate"); + const sendPromptJob = jobsByType.get("opencode.sendUserPrompt"); + + for (const jobType of SETUP_JOBS) { + const job = jobsByType.get(jobType); + if (!job) { + setupJobs[jobType] = { type: jobType, state: "pending" }; + isSetupComplete = false; + } else { + const derivedState = getQueueJobDerivedState(job); + const derivedError = getQueueJobDerivedError(job); + const isTerminalErrorState = + derivedState === "failed" || derivedState === "exhausted"; + + setupJobs[jobType] = { + type: jobType, + state: derivedState, + ...(derivedError ? { error: derivedError } : {}), + completedAt: job.updatedAt.getTime(), + createdAt: job.createdAt.getTime(), + }; + if (isTerminalErrorState) { + // docker.ensureRunning replaces docker.composeUp + docker.waitReady, + // so suppress their errors when ensureRunning is active or succeeded + const ensureRunningState = dockerEnsureRunningJob?.state; + const ensureRunningActive = + ensureRunningState === "queued" || + ensureRunningState === "running" || + ensureRunningState === "succeeded"; + const isRecoveredByEnsureRunning = + (jobType === "docker.composeUp" || + jobType === "docker.waitReady") && + ensureRunningActive; + if (!isRecoveredByEnsureRunning) { + hasError = true; + errorMessage = + derivedError || + (derivedState === "exhausted" + ? `${jobType} exhausted all retry attempts` + : `${jobType} failed`); + isSetupComplete = false; + } + } + if (jobType === "opencode.sendUserPrompt") { + promptSentAt = job.updatedAt.getTime(); + } + } + } + + const dockerJob = + dockerComposeUpJob?.state === "succeeded" + ? dockerComposeUpJob + : dockerEnsureRunningJob?.state === "succeeded" + ? dockerEnsureRunningJob + : dockerComposeUpJob || dockerEnsureRunningJob; + + // docker.ensureRunning does its own health checking internally, + // so treat docker.waitReady as implicitly done when ensureRunning succeeded + const dockerWaitDone = + dockerWaitReadyJob?.state === "succeeded" || + dockerEnsureRunningJob?.state === "succeeded"; + + let currentStep = 0; + if ( + projectCreateJob?.state === "succeeded" && + dockerJob?.state === "succeeded" && + dockerWaitDone && + sessionCreateJob?.state === "succeeded" && + sendPromptJob?.state === "succeeded" + ) { + currentStep = 4; + } else if ( + projectCreateJob?.state === "succeeded" && + dockerJob?.state === "succeeded" && + dockerWaitDone && + sessionCreateJob?.state === "succeeded" + ) { + currentStep = 3; + isSetupComplete = false; + } else if ( + projectCreateJob?.state === "succeeded" && + dockerJob?.state === "succeeded" && + dockerWaitDone + ) { + currentStep = 2; + isSetupComplete = false; + } else if (projectCreateJob?.state === "succeeded") { + currentStep = 1; + isSetupComplete = false; + } + + if ( + currentStep === 4 && + project.initialPromptSent && + !project.userPromptCompleted + ) { + await markUserPromptCompleted(input.projectId); + } + + let jobTimeoutWarning: string | undefined; + const now = Date.now(); + for (const jobType of SETUP_JOBS) { + const job = jobsByType.get(jobType); + if (job && (job.state === "running" || job.state === "queued")) { + const elapsed = now - job.createdAt.getTime(); + if (elapsed > JOB_TIMEOUT_MS) { + jobTimeoutWarning = `${jobType} has been running for too long`; + break; + } + } + } + + return { + projectId: input.projectId, + currentStep, + setupJobs, + hasError, + errorMessage, + isSetupComplete, + promptSentAt, + jobTimeoutWarning, + }; + }, +}); diff --git a/src/actions/projects.settings.ts b/src/actions/projects.settings.ts new file mode 100644 index 0000000..92b02b6 --- /dev/null +++ b/src/actions/projects.settings.ts @@ -0,0 +1,77 @@ +import { ActionError, defineAction } from "astro:actions"; +import { z } from "astro/zod"; +import { logger } from "@/server/logger"; +import { + isProjectOwnedByUser, + updateOpencodeJsonModel, + updateProjectDisplayIdentity, + updateProjectModel, +} from "@/server/projects/projects.model"; + +export const updateModel = defineAction({ + input: z.object({ + projectId: z.string(), + model: z.string().nullable(), + }), + handler: async (input, context) => { + const user = context.locals.user; + if (!user) { + throw new ActionError({ + code: "UNAUTHORIZED", + message: "You must be logged in to update a project", + }); + } + + const isOwner = await isProjectOwnedByUser(input.projectId, user.id); + if (!isOwner) { + throw new ActionError({ + code: "FORBIDDEN", + message: "You don't have access to this project", + }); + } + + await updateProjectModel(input.projectId, input.model); + + if (input.model) { + try { + await updateOpencodeJsonModel(input.projectId, input.model); + } catch (_error) { + logger.warn( + "Updated model in database but failed to update opencode.json", + ); + } + } + + return { success: true }; + }, +}); + +export const updateIdentity = defineAction({ + accept: "json", + input: z.object({ + projectId: z.string(), + name: z.string().min(1).max(64), + icon: z.string().emoji().max(4), + }), + handler: async (input, context) => { + const user = context.locals.user; + if (!user) { + throw new ActionError({ + code: "UNAUTHORIZED", + message: "You must be logged in", + }); + } + const isOwner = await isProjectOwnedByUser(input.projectId, user.id); + if (!isOwner) { + throw new ActionError({ + code: "FORBIDDEN", + message: "You don't have access to this project", + }); + } + await updateProjectDisplayIdentity(input.projectId, { + name: input.name, + icon: input.icon, + }); + return { success: true }; + }, +}); diff --git a/src/actions/projects.ts b/src/actions/projects.ts index b34e8d8..2828d93 100644 --- a/src/actions/projects.ts +++ b/src/actions/projects.ts @@ -1,966 +1,33 @@ -import { ActionError, defineAction } from "astro:actions"; -import { randomBytes } from "node:crypto"; -import { z } from "astro/zod"; -import { logger } from "@/server/logger"; -import { getAvailableModels } from "@/server/opencode/models"; -import { restartGlobalOpencode } from "@/server/opencode/runtime"; - +import { create } from "./projects.create"; +import { get, list } from "./projects.crud"; +import { deleteAll, deleteProject } from "./projects.delete"; +import { restart, stop } from "./projects.lifecycle"; +import { restartOpencode } from "./projects.opencode"; +import { deploy, rollback, stopProduction } from "./projects.production"; +import { getProductionHistory } from "./projects.production-history"; import { - getProjectById, - getProjectsByUserId, - isProjectOwnedByUser, markInitialPromptCompleted, markInitialPromptSent, - markUserPromptCompleted, - updateOpencodeJsonModel, - updateProjectDisplayIdentity, - updateProjectModel, - updateProjectStatus, -} from "@/server/projects/projects.model"; -import { - enqueueDeleteAllProjectsForUser, - enqueueDockerStop, - enqueueProductionBuild, - enqueueProductionStop, - enqueueProjectCreate, - enqueueProjectDelete, -} from "@/server/queue/enqueue"; -import { - getQueueJobDerivedError, - getQueueJobDerivedState, -} from "@/server/queue/job-state"; +} from "./projects.prompt"; +import { getQueueStatus } from "./projects.queue-status"; +import { updateIdentity, updateModel } from "./projects.settings"; export const projects = { - create: defineAction({ - accept: "json", - input: z.object({ - prompt: z.string().min(1, "Please describe your website"), - model: z.string().optional(), - attachments: z.string().optional(), - }), - handler: async (input, context) => { - const user = context.locals.user; - if (!user) { - throw new ActionError({ - code: "UNAUTHORIZED", - message: "You must be logged in to create a project", - }); - } - - const availableModels = await getAvailableModels(); - if (availableModels.length === 0) { - throw new ActionError({ - code: "BAD_REQUEST", - message: "No models available. Please check your provider settings.", - }); - } - - let attachments: - | Array<{ - filename: string; - mime: string; - dataUrl: string; - kind?: "image" | "text"; - textContent?: string; - }> - | undefined; - if (input.attachments) { - try { - attachments = JSON.parse(input.attachments); - } catch {} - } - - const projectId = randomBytes(12).toString("hex"); - - try { - await enqueueProjectCreate({ - projectId, - ownerUserId: user.id, - prompt: input.prompt, - model: input.model ?? null, - attachments, - }); - } catch (err) { - logger.error({ err }, "Failed to enqueue project creation"); - throw new ActionError({ - code: "INTERNAL_SERVER_ERROR", - message: "Failed to start project creation", - }); - } - - return { - success: true, - projectId, - }; - }, - }), - - list: defineAction({ - handler: async (_input, context) => { - const user = context.locals.user; - if (!user) { - throw new ActionError({ - code: "UNAUTHORIZED", - message: "You must be logged in to list projects", - }); - } - - const { getProjectRuntimeUrls } = await import( - "@/server/projects/projectUrls" - ); - const projects = await getProjectsByUserId(user.id); - const projectsWithUrls = await Promise.all( - projects.map(async (project) => { - const urls = await getProjectRuntimeUrls(project); - return { - ...project, - previewUrl: urls.preview.preferred, - previewUrls: urls.preview, - productionUrls: urls.production, - }; - }), - ); - return { projects: projectsWithUrls }; - }, - }), - - get: defineAction({ - input: z.object({ - projectId: z.string(), - }), - handler: async (input, context) => { - const user = context.locals.user; - if (!user) { - throw new ActionError({ - code: "UNAUTHORIZED", - message: "You must be logged in to view a project", - }); - } - - const project = await getProjectById(input.projectId); - if (!project) { - throw new ActionError({ - code: "NOT_FOUND", - message: "Project not found", - }); - } - - if (project.ownerUserId !== user.id) { - throw new ActionError({ - code: "FORBIDDEN", - message: "You don't have access to this project", - }); - } - - return { project }; - }, - }), - - delete: defineAction({ - input: z.object({ - projectId: z.string(), - }), - handler: async (input, context) => { - const user = context.locals.user; - if (!user) { - throw new ActionError({ - code: "UNAUTHORIZED", - message: "You must be logged in to delete a project", - }); - } - - const isOwner = await isProjectOwnedByUser(input.projectId, user.id); - if (!isOwner) { - throw new ActionError({ - code: "FORBIDDEN", - message: "You don't have access to this project", - }); - } - - try { - await updateProjectStatus(input.projectId, "deleting"); - } catch {} - - try { - const { cancelActiveProductionJobs } = await import( - "@/server/productions/productions.model" - ); - await cancelActiveProductionJobs(input.projectId); - } catch {} - - const job = await enqueueProjectDelete({ - projectId: input.projectId, - requestedByUserId: user.id, - }); - - return { success: true, jobId: job.id }; - }, - }), - - stop: defineAction({ - input: z.object({ - projectId: z.string(), - }), - handler: async (input, context) => { - const user = context.locals.user; - if (!user) { - throw new ActionError({ - code: "UNAUTHORIZED", - message: "You must be logged in to stop a project", - }); - } - - const isOwner = await isProjectOwnedByUser(input.projectId, user.id); - if (!isOwner) { - throw new ActionError({ - code: "FORBIDDEN", - message: "You don't have access to this project", - }); - } - - const job = await enqueueDockerStop({ - projectId: input.projectId, - reason: "user", - }); - - return { success: true, jobId: job.id }; - }, - }), - - restart: defineAction({ - input: z.object({ - projectId: z.string(), - }), - handler: async (input, context) => { - const user = context.locals.user; - if (!user) { - throw new ActionError({ - code: "UNAUTHORIZED", - message: "You must be logged in to restart a project", - }); - } - - const isOwner = await isProjectOwnedByUser(input.projectId, user.id); - if (!isOwner) { - throw new ActionError({ - code: "FORBIDDEN", - message: "You don't have access to this project", - }); - } - - const { getProjectById } = await import( - "@/server/projects/projects.model" - ); - const project = await getProjectById(input.projectId); - if (!project) { - throw new ActionError({ - code: "NOT_FOUND", - message: "Project not found", - }); - } - - const { enqueueDockerEnsureRunning } = await import( - "@/server/queue/enqueue" - ); - - const { updateProjectStatus } = await import( - "@/server/projects/projects.model" - ); - - await updateProjectStatus(input.projectId, "starting"); - - const job = await enqueueDockerEnsureRunning({ - projectId: input.projectId, - reason: "user", - }); - - return { success: true, jobId: job.id }; - }, - }), - - deploy: defineAction({ - input: z.object({ - projectId: z.string(), - }), - handler: async (input, context) => { - const user = context.locals.user; - if (!user) { - throw new ActionError({ - code: "UNAUTHORIZED", - message: "You must be logged in to deploy a project", - }); - } - - const isOwner = await isProjectOwnedByUser(input.projectId, user.id); - if (!isOwner) { - throw new ActionError({ - code: "FORBIDDEN", - message: "You don't have access to this project", - }); - } - - const project = await getProjectById(input.projectId); - if (!project) { - throw new ActionError({ - code: "NOT_FOUND", - message: "Project not found", - }); - } - - if (project.status !== "running") { - throw new ActionError({ - code: "BAD_REQUEST", - message: `Cannot deploy project while it's ${project.status}`, - }); - } - - const { hasActiveDeployment, updateProductionStatus } = await import( - "@/server/productions/productions.model" - ); - if (await hasActiveDeployment(input.projectId)) { - throw new ActionError({ - code: "CONFLICT", - message: "A deployment is already in progress", - }); - } - - await updateProductionStatus(input.projectId, "queued", { - productionError: null, - }); - - const job = await enqueueProductionBuild({ - projectId: input.projectId, - }); - - return { success: true, jobId: job.id }; - }, - }), - - stopProduction: defineAction({ - input: z.object({ - projectId: z.string(), - }), - handler: async (input, context) => { - const user = context.locals.user; - if (!user) { - throw new ActionError({ - code: "UNAUTHORIZED", - message: "You must be logged in to stop production", - }); - } - - const isOwner = await isProjectOwnedByUser(input.projectId, user.id); - if (!isOwner) { - throw new ActionError({ - code: "FORBIDDEN", - message: "You don't have access to this project", - }); - } - - const project = await getProjectById(input.projectId); - if (!project) { - throw new ActionError({ - code: "NOT_FOUND", - message: "Project not found", - }); - } - - const { cancelActiveProductionJobs } = await import( - "@/server/productions/productions.model" - ); - await cancelActiveProductionJobs(input.projectId); - - const job = await enqueueProductionStop(input.projectId); - - return { success: true, jobId: job.id }; - }, - }), - - deleteAll: defineAction({ - handler: async (_input, context) => { - const user = context.locals.user; - if (!user) { - throw new ActionError({ - code: "UNAUTHORIZED", - message: "You must be logged in to delete projects", - }); - } - - const job = await enqueueDeleteAllProjectsForUser({ userId: user.id }); - - return { success: true, jobId: job.id }; - }, - }), - - updateModel: defineAction({ - input: z.object({ - projectId: z.string(), - model: z.string().nullable(), - }), - handler: async (input, context) => { - const user = context.locals.user; - if (!user) { - throw new ActionError({ - code: "UNAUTHORIZED", - message: "You must be logged in to update a project", - }); - } - - const isOwner = await isProjectOwnedByUser(input.projectId, user.id); - if (!isOwner) { - throw new ActionError({ - code: "FORBIDDEN", - message: "You don't have access to this project", - }); - } - - await updateProjectModel(input.projectId, input.model); - - if (input.model) { - try { - await updateOpencodeJsonModel(input.projectId, input.model); - } catch (_error) { - logger.warn( - "Updated model in database but failed to update opencode.json", - ); - } - } - - return { success: true }; - }, - }), - - rollback: defineAction({ - input: z.object({ - projectId: z.string(), - toHash: z.string(), - }), - handler: async (input, context) => { - const user = context.locals.user; - if (!user) { - throw new ActionError({ - code: "UNAUTHORIZED", - message: "You must be logged in", - }); - } - - const isOwner = await isProjectOwnedByUser(input.projectId, user.id); - if (!isOwner) { - throw new ActionError({ - code: "FORBIDDEN", - message: "You don't have access to this project", - }); - } - - const project = await getProjectById(input.projectId); - if (!project) { - throw new ActionError({ - code: "NOT_FOUND", - message: "Project not found", - }); - } - - const { getProductionVersions } = await import( - "@/server/productions/cleanup" - ); - const { getProductionPath } = await import("@/server/projects/paths"); - const { updateProductionStatus } = await import( - "@/server/productions/productions.model" - ); - - const versions = await getProductionVersions(input.projectId); - const targetVersion = versions.find((v) => v.hash === input.toHash); - - if (!targetVersion) { - throw new ActionError({ - code: "NOT_FOUND", - message: "Target version not found", - }); - } - - if (targetVersion.isActive) { - throw new ActionError({ - code: "BAD_REQUEST", - message: "Target version is already active", - }); - } - - const productionPort = project.productionPort; - if (!productionPort) { - throw new ActionError({ - code: "BAD_REQUEST", - message: "Project not initialized for production", - }); - } - - // Build Docker image for rollback version - const productionPath = getProductionPath(project.id, input.toHash); - const { getProductionContainerName, getProductionImageName } = - await import("@/server/projects/paths"); - const imageName = getProductionImageName(project.id, input.toHash); - - const { spawnCommand } = await import("@/server/utils/execAsync"); - const buildResult = await spawnCommand( - "docker", - ["build", "-t", imageName, "-f", "Dockerfile.prod", "."], - { cwd: productionPath }, - ); - - if (!buildResult.success) { - throw new ActionError({ - code: "INTERNAL_SERVER_ERROR", - message: `Docker build failed: ${buildResult.stderr.slice(0, 200)}`, - }); - } - - // Stop and remove old container - const containerName = getProductionContainerName(project.id); - const stopResult = await spawnCommand("docker", ["stop", containerName]); - const removeResult = await spawnCommand("docker", ["rm", containerName]); - - if (!stopResult.success || !removeResult.success) { - // Container might not exist, continue - } - - // Start new container - const runResult = await spawnCommand("docker", [ - "run", - "-d", - "--name", - containerName, - "-p", - `${productionPort}:3000`, - "--restart", - "unless-stopped", - imageName, - ]); - - if (!runResult.success) { - throw new ActionError({ - code: "INTERNAL_SERVER_ERROR", - message: `Docker run failed: ${runResult.stderr.slice(0, 200)}`, - }); - } - - // Update symlink - const { getProductionCurrentSymlink } = await import( - "@/server/projects/paths" - ); - const symlinkPath = getProductionCurrentSymlink(project.id); - const hashPath = getProductionPath(project.id, input.toHash); - const tempSymlink = `${symlinkPath}.tmp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - - const fs = await import("node:fs/promises"); - try { - await fs.unlink(tempSymlink).catch(() => {}); - await fs.symlink(hashPath, tempSymlink); - await fs.rename(tempSymlink, symlinkPath); - } catch { - // Symlink update failed, but container is running - } - - const { getCanonicalProductionUrl } = await import( - "@/server/productions/productionUrl" - ); - await updateProductionStatus(input.projectId, "running", { - productionHash: input.toHash, - productionStartedAt: new Date(), - productionUrl: await getCanonicalProductionUrl(project), - }); - - return { success: true }; - }, - }), - - markInitialPromptSent: defineAction({ - input: z.object({ - projectId: z.string(), - }), - handler: async (input, context) => { - const user = context.locals.user; - if (!user) { - throw new ActionError({ - code: "UNAUTHORIZED", - message: "You must be logged in", - }); - } - - const isOwner = await isProjectOwnedByUser(input.projectId, user.id); - if (!isOwner) { - throw new ActionError({ - code: "FORBIDDEN", - message: "You don't have access to this project", - }); - } - - await markInitialPromptSent(input.projectId); - return { success: true }; - }, - }), - - markInitialPromptCompleted: defineAction({ - input: z.object({ - projectId: z.string(), - }), - handler: async (input, context) => { - const user = context.locals.user; - if (!user) { - throw new ActionError({ - code: "UNAUTHORIZED", - message: "You must be logged in", - }); - } - - const isOwner = await isProjectOwnedByUser(input.projectId, user.id); - if (!isOwner) { - throw new ActionError({ - code: "FORBIDDEN", - message: "You don't have access to this project", - }); - } - - await markInitialPromptCompleted(input.projectId); - return { success: true }; - }, - }), - - getQueueStatus: defineAction({ - input: z.object({ - projectId: z.string(), - }), - handler: async (input, context) => { - const user = context.locals.user; - if (!user) { - throw new ActionError({ - code: "UNAUTHORIZED", - message: "You must be logged in", - }); - } - - const isOwner = await isProjectOwnedByUser(input.projectId, user.id); - if (!isOwner) { - throw new ActionError({ - code: "NOT_FOUND", - message: "Project not found", - }); - } - - const project = await getProjectById(input.projectId); - if (!project) { - throw new ActionError({ - code: "NOT_FOUND", - message: "Project not found", - }); - } - - const { listJobs } = await import("@/server/queue/queue.model"); - - const SETUP_JOBS = [ - "project.create", - "docker.composeUp", - "docker.ensureRunning", - "docker.waitReady", - "opencode.sessionCreate", - "opencode.sendUserPrompt", - ] as const; - - const LEGACY_SEND_PROMPT_JOB = "opencode.sendInitialPrompt"; - const JOB_TIMEOUT_MS = 5 * 60 * 1000; - - const jobs = await listJobs({ - projectId: input.projectId, - limit: 100, - }); - - type SetupJob = { - type: string; - state: "pending" | (typeof jobs)[number]["state"] | "exhausted"; - error?: string; - completedAt?: number; - createdAt?: number; - }; - - const jobsByType = new Map(); - for (const job of jobs) { - if ( - SETUP_JOBS.includes(job.type as (typeof SETUP_JOBS)[number]) && - !jobsByType.has(job.type) - ) { - jobsByType.set(job.type, job); - } - if ( - job.type === LEGACY_SEND_PROMPT_JOB && - !jobsByType.has("opencode.sendUserPrompt") - ) { - jobsByType.set("opencode.sendUserPrompt", job); - } - } - - const setupJobs: Record = {}; - let hasError = false; - let errorMessage: string | undefined; - let promptSentAt: number | undefined; - let isSetupComplete = true; - - const projectCreateJob = jobsByType.get("project.create"); - const dockerComposeUpJob = jobsByType.get("docker.composeUp"); - const dockerEnsureRunningJob = jobsByType.get("docker.ensureRunning"); - const dockerWaitReadyJob = jobsByType.get("docker.waitReady"); - const sessionCreateJob = jobsByType.get("opencode.sessionCreate"); - const sendPromptJob = jobsByType.get("opencode.sendUserPrompt"); - - for (const jobType of SETUP_JOBS) { - const job = jobsByType.get(jobType); - if (!job) { - setupJobs[jobType] = { type: jobType, state: "pending" }; - isSetupComplete = false; - } else { - const derivedState = getQueueJobDerivedState(job); - const derivedError = getQueueJobDerivedError(job); - const isTerminalErrorState = - derivedState === "failed" || derivedState === "exhausted"; - - setupJobs[jobType] = { - type: jobType, - state: derivedState, - ...(derivedError ? { error: derivedError } : {}), - completedAt: job.updatedAt.getTime(), - createdAt: job.createdAt.getTime(), - }; - if (isTerminalErrorState) { - // docker.ensureRunning replaces docker.composeUp + docker.waitReady, - // so suppress their errors when ensureRunning is active or succeeded - const ensureRunningState = dockerEnsureRunningJob?.state; - const ensureRunningActive = - ensureRunningState === "queued" || - ensureRunningState === "running" || - ensureRunningState === "succeeded"; - const isRecoveredByEnsureRunning = - (jobType === "docker.composeUp" || - jobType === "docker.waitReady") && - ensureRunningActive; - if (!isRecoveredByEnsureRunning) { - hasError = true; - errorMessage = - derivedError || - (derivedState === "exhausted" - ? `${jobType} exhausted all retry attempts` - : `${jobType} failed`); - isSetupComplete = false; - } - } - if (jobType === "opencode.sendUserPrompt") { - promptSentAt = job.updatedAt.getTime(); - } - } - } - - const dockerJob = - dockerComposeUpJob?.state === "succeeded" - ? dockerComposeUpJob - : dockerEnsureRunningJob?.state === "succeeded" - ? dockerEnsureRunningJob - : dockerComposeUpJob || dockerEnsureRunningJob; - - // docker.ensureRunning does its own health checking internally, - // so treat docker.waitReady as implicitly done when ensureRunning succeeded - const dockerWaitDone = - dockerWaitReadyJob?.state === "succeeded" || - dockerEnsureRunningJob?.state === "succeeded"; - - let currentStep = 0; - if ( - projectCreateJob?.state === "succeeded" && - dockerJob?.state === "succeeded" && - dockerWaitDone && - sessionCreateJob?.state === "succeeded" && - sendPromptJob?.state === "succeeded" - ) { - currentStep = 4; - } else if ( - projectCreateJob?.state === "succeeded" && - dockerJob?.state === "succeeded" && - dockerWaitDone && - sessionCreateJob?.state === "succeeded" - ) { - currentStep = 3; - isSetupComplete = false; - } else if ( - projectCreateJob?.state === "succeeded" && - dockerJob?.state === "succeeded" && - dockerWaitDone - ) { - currentStep = 2; - isSetupComplete = false; - } else if (projectCreateJob?.state === "succeeded") { - currentStep = 1; - isSetupComplete = false; - } - - if ( - currentStep === 4 && - project.initialPromptSent && - !project.userPromptCompleted - ) { - await markUserPromptCompleted(input.projectId); - } - - let jobTimeoutWarning: string | undefined; - const now = Date.now(); - for (const jobType of SETUP_JOBS) { - const job = jobsByType.get(jobType); - if (job && (job.state === "running" || job.state === "queued")) { - const elapsed = now - job.createdAt.getTime(); - if (elapsed > JOB_TIMEOUT_MS) { - jobTimeoutWarning = `${jobType} has been running for too long`; - break; - } - } - } - - return { - projectId: input.projectId, - currentStep, - setupJobs, - hasError, - errorMessage, - isSetupComplete, - promptSentAt, - jobTimeoutWarning, - }; - }, - }), - - getProductionHistory: defineAction({ - input: z.object({ - projectId: z.string(), - }), - handler: async (input, context) => { - const user = context.locals.user; - if (!user) { - throw new ActionError({ - code: "UNAUTHORIZED", - message: "You must be logged in", - }); - } - - const isOwner = await isProjectOwnedByUser(input.projectId, user.id); - if (!isOwner) { - throw new ActionError({ - code: "FORBIDDEN", - message: "You don't have access to this project", - }); - } - - const project = await getProjectById(input.projectId); - if (!project) { - throw new ActionError({ - code: "NOT_FOUND", - message: "Project not found", - }); - } - - const { getProductionVersions } = await import( - "@/server/productions/cleanup" - ); - - const { getProjectRuntimeUrls } = await import( - "@/server/projects/projectUrls" - ); - const versions = await getProductionVersions(input.projectId); - const productionPort = project.productionPort; - const urls = await getProjectRuntimeUrls(project); - - return { - productionPort, - baseUrl: urls.production.preferred, - versions: versions.map((v) => { - return { - hash: v.hash, - isActive: v.isActive, - createdAt: v.mtimeIso, - url: v.isActive - ? (urls.production.preferred ?? undefined) - : undefined, - baseUrl: urls.production.local, - previewUrl: urls.production.tailscale ?? undefined, - productionPort, - }; - }), - }; - }, - }), - - restartOpencode: defineAction({ - accept: "json", - input: z.object({ - projectId: z.string(), - }), - handler: async (input, context) => { - const user = context.locals.user; - if (!user) { - throw new ActionError({ - code: "UNAUTHORIZED", - message: "You must be logged in to restart the agent", - }); - } - - const isOwner = await isProjectOwnedByUser(input.projectId, user.id); - if (!isOwner) { - throw new ActionError({ - code: "NOT_FOUND", - message: "Project not found", - }); - } - - try { - logger.info( - { projectId: input.projectId, userId: user.id }, - "Restarting OpenCode agent", - ); - await restartGlobalOpencode(); - return { success: true }; - } catch (error) { - logger.error( - { error, projectId: input.projectId }, - "Failed to restart OpenCode agent", - ); - throw new ActionError({ - code: "INTERNAL_SERVER_ERROR", - message: - error instanceof Error ? error.message : "Failed to restart agent", - }); - } - }, - }), - - updateIdentity: defineAction({ - accept: "json", - input: z.object({ - projectId: z.string(), - name: z.string().min(1).max(64), - icon: z.string().emoji().max(4), - }), - handler: async (input, context) => { - const user = context.locals.user; - if (!user) { - throw new ActionError({ - code: "UNAUTHORIZED", - message: "You must be logged in", - }); - } - const isOwner = await isProjectOwnedByUser(input.projectId, user.id); - if (!isOwner) { - throw new ActionError({ - code: "FORBIDDEN", - message: "You don't have access to this project", - }); - } - await updateProjectDisplayIdentity(input.projectId, { - name: input.name, - icon: input.icon, - }); - return { success: true }; - }, - }), + create, + list, + get, + delete: deleteProject, + stop, + restart, + deploy, + stopProduction, + deleteAll, + updateModel, + rollback, + markInitialPromptSent, + markInitialPromptCompleted, + getQueueStatus, + getProductionHistory, + restartOpencode, + updateIdentity, }; diff --git a/src/hooks/useChatActions.ts b/src/hooks/useChatActions.ts new file mode 100644 index 0000000..c6f859e --- /dev/null +++ b/src/hooks/useChatActions.ts @@ -0,0 +1,268 @@ +import { actions } from "astro:actions"; +import { useCallback, useState } from "react"; +import { toast } from "sonner"; +import type { RawSessionMessage } from "@/lib/chat/buildHistoryItems"; +import { getSessionContextUsage } from "@/lib/chat/sessionContextUsage"; +import type { PromptAttachmentPart } from "@/types/message"; + +const CHAT_HISTORY_PAGE_LIMIT = 50; + +interface UseChatActionsOptions { + projectId: string; + models: ReadonlyArray<{ + id: string; + provider: string; + supportsAttachments?: boolean; + contextLimit?: number; + }>; + sessionId: string | null; + isStreaming: boolean; + currentModel: { providerID: string; modelID: string } | null; + pendingAttachments: PromptAttachmentPart[]; + pendingPermission: { requestId: string } | null; + pendingQuestion: { requestId: string } | null; + fetchJson: (url: string, init?: RequestInit) => Promise; + showRequestError: (title: string, error: unknown) => void; + setIsStreaming: (value: boolean) => void; + setSessionContextLoaded: (loaded: boolean) => void; + setSessionContextUsage: ( + usage: ReturnType, + ) => void; + setCurrentModel: ( + model: { providerID: string; modelID: string } | null, + ) => void; + setPendingAttachments: (attachments: PromptAttachmentPart[]) => void; + setPendingAttachmentError: (error: string | null) => void; + setPendingPermission: (permission: null) => void; + setPendingQuestion: (question: null) => void; +} + +export function useChatActions({ + projectId, + models, + sessionId, + isStreaming, + currentModel, + pendingAttachments, + pendingPermission, + pendingQuestion, + fetchJson, + showRequestError, + setIsStreaming, + setSessionContextLoaded, + setSessionContextUsage, + setCurrentModel, + setPendingAttachments, + setPendingAttachmentError, + setPendingPermission, + setPendingQuestion, +}: UseChatActionsOptions) { + const [expandedTools, setExpandedTools] = useState>(new Set()); + + const handleStop = useCallback(async () => { + if (!sessionId || !isStreaming) return; + + try { + await fetchJson( + `/api/projects/${projectId}/opencode/session/${sessionId}/abort`, + { method: "POST" }, + ); + } catch (error) { + showRequestError("Failed to stop response", error); + } + }, [fetchJson, isStreaming, projectId, sessionId, showRequestError]); + + const handleCompact = useCallback(async () => { + if (!sessionId) return; + if (!currentModel) { + toast.info("Select a model before compacting the conversation"); + return; + } + + setIsStreaming(true); + setSessionContextLoaded(false); + try { + await fetchJson( + `/api/projects/${projectId}/opencode/session/${sessionId}/summarize`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + providerID: currentModel.providerID, + modelID: currentModel.modelID, + auto: false, + }), + }, + ); + + const messagesData = await fetchJson< + RawSessionMessage[] | { messages?: RawSessionMessage[] } + >( + `/api/projects/${projectId}/opencode/session/${sessionId}/message?limit=${CHAT_HISTORY_PAGE_LIMIT}`, + ); + const messages = Array.isArray(messagesData) + ? messagesData + : (messagesData.messages ?? []); + setSessionContextUsage( + getSessionContextUsage(messages, currentModel, models), + ); + toast.success("Conversation compacted"); + } catch (error) { + showRequestError("Failed to compact conversation", error); + } finally { + setIsStreaming(false); + setSessionContextLoaded(true); + } + }, [ + currentModel, + fetchJson, + models, + projectId, + sessionId, + setIsStreaming, + setSessionContextLoaded, + setSessionContextUsage, + showRequestError, + ]); + + const handleModelChange = async (compositeKey: string) => { + const [providerId, ...modelIdParts] = compositeKey.split(":"); + const modelId = modelIdParts.join(":"); + const newModelConfig = models.find( + (m) => m.id === modelId && m.provider === providerId, + ); + const newModelSupportsAttachments = + newModelConfig?.supportsAttachments ?? true; + + if (pendingAttachments.length > 0 && !newModelSupportsAttachments) { + const textAttachments = pendingAttachments.filter( + (a) => a.kind !== "image", + ); + const imageAttachments = pendingAttachments.filter( + (a) => a.kind === "image", + ); + if (imageAttachments.length > 0) { + setPendingAttachments(textAttachments); + setPendingAttachmentError(null); + toast.info("Images cleared", { + description: "The selected model doesn't support image input", + }); + } + } + + const newModel = newModelConfig + ? { providerID: newModelConfig.provider, modelID: newModelConfig.id } + : null; + const previousModel = currentModel; + setCurrentModel(newModel); + + try { + const modelString = newModel + ? `${newModel.providerID}/${newModel.modelID}` + : null; + const result = await actions.projects.updateModel({ + projectId, + model: modelString || "", + }); + if (!result.data?.success) setCurrentModel(previousModel); + } catch { + setCurrentModel(previousModel); + } + }; + + const handlePermissionDecision = useCallback( + async (reply: "once" | "always" | "reject") => { + if (!pendingPermission) return; + + try { + await fetchJson( + `/api/projects/${projectId}/opencode/permission/${pendingPermission.requestId}/reply`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ reply }), + }, + ); + setPendingPermission(null); + } catch (error) { + showRequestError("Failed to respond to permission request", error); + } + }, + [ + fetchJson, + pendingPermission, + projectId, + setPendingPermission, + showRequestError, + ], + ); + + const handleQuestionSubmit = useCallback( + async (answers: string[][]) => { + if (!pendingQuestion) return; + + try { + await fetchJson( + `/api/projects/${projectId}/opencode/question/${pendingQuestion.requestId}/reply`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ answers }), + }, + ); + setPendingQuestion(null); + } catch (error) { + showRequestError("Failed to submit question response", error); + } + }, + [ + fetchJson, + pendingQuestion, + projectId, + setPendingQuestion, + showRequestError, + ], + ); + + const handleQuestionReject = useCallback(async () => { + if (!pendingQuestion) return; + + try { + await fetchJson( + `/api/projects/${projectId}/opencode/question/${pendingQuestion.requestId}/reject`, + { + method: "POST", + }, + ); + setPendingQuestion(null); + } catch (error) { + showRequestError("Failed to reject question", error); + } + }, [ + fetchJson, + pendingQuestion, + projectId, + setPendingQuestion, + showRequestError, + ]); + + const toggleToolExpanded = (id: string) => { + setExpandedTools((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + return { + expandedTools, + handleStop, + handleCompact, + handleModelChange, + handlePermissionDecision, + handleQuestionSubmit, + handleQuestionReject, + toggleToolExpanded, + }; +} diff --git a/src/hooks/useChatHistory.ts b/src/hooks/useChatHistory.ts new file mode 100644 index 0000000..3317c19 --- /dev/null +++ b/src/hooks/useChatHistory.ts @@ -0,0 +1,516 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; +import { + buildHistoryItems, + type RawSessionMessage, +} from "@/lib/chat/buildHistoryItems"; +import { getSessionContextUsage } from "@/lib/chat/sessionContextUsage"; +import type { OpencodeDiagnostic } from "@/server/opencode/diagnostics"; +import type { ProjectLiveState } from "@/types/live"; + +const CHAT_HISTORY_PAGE_LIMIT = 50; + +interface ProxyErrorPayload { + message?: string; + error?: string; + title?: string; + category?: string; + source?: string; +} + +class ChatApiError extends Error { + status: number; + body: ProxyErrorPayload | null; + + constructor(message: string, status: number, body: ProxyErrorPayload | null) { + super(message); + this.name = "ChatApiError"; + this.status = status; + this.body = body; + } +} + +function normalizeMessageModel( + model: { providerID: string; modelID: string }, + availableModels: ReadonlyArray<{ id: string; provider: string }>, +): { providerID: string; modelID: string } { + if (model.modelID.includes("/")) return model; + if (model.providerID !== "opencode") return model; + + const matchingModel = availableModels.find( + (m) => + m.provider === "opencode" && + (m.id === model.modelID || m.id.endsWith(`/${model.modelID}`)), + ); + + return matchingModel + ? { providerID: model.providerID, modelID: matchingModel.id } + : model; +} + +interface UseChatHistoryOptions { + projectId: string; + models: ReadonlyArray<{ + id: string; + provider: string; + contextLimit?: number; + }>; + sessionId: string | null; + opencodeReady: boolean; + historyLoaded: boolean; + presenceLoaded: boolean; + userPromptMessageId: string | null; + sessionTitle: string | null; + currentModel: { providerID: string; modelID: string } | null; + liveData: ProjectLiveState | null | undefined; + setSessionId: (sessionId: string | null) => void; + setRevertMessageId: (messageId: string | null) => void; + setCurrentModel: ( + model: { providerID: string; modelID: string } | null, + ) => void; + setHistoryLoaded: (loaded: boolean) => void; + setSessionTitle: (title: string | null) => void; + setSessionContextUsage: ( + usage: ReturnType, + ) => void; + setPendingPermission: ( + permission: { + requestId: string; + sessionId: string; + permission: string; + patterns: string[]; + messageId?: string; + toolCallId?: string; + } | null, + ) => void; + setPendingQuestion: ( + question: { + requestId: string; + sessionId: string; + questions: Array<{ + header: string; + question: string; + options: Array<{ label: string; description: string }>; + multiple?: boolean; + custom?: boolean; + }>; + messageId?: string; + toolCallId?: string; + } | null, + ) => void; + setTodos: ( + todos: Array<{ content: string; status: string; priority: string }>, + ) => void; + setLatestDiagnostic: (diagnostic: OpencodeDiagnostic | null) => void; + setOpenCodeReady: (ready: boolean) => void; + setInitialPromptSent: (sent: boolean) => void; + setUserPromptMessageId: (messageId: string | null) => void; + setProjectPrompt: (prompt: string) => void; + setPresenceLoaded: (loaded: boolean) => void; + setItems: (items: ReturnType) => void; + scrollToBottom: () => void; +} + +export function useChatHistory({ + projectId, + models, + sessionId, + opencodeReady, + historyLoaded, + presenceLoaded, + userPromptMessageId, + sessionTitle, + currentModel, + liveData, + setSessionId, + setRevertMessageId, + setCurrentModel, + setHistoryLoaded, + setSessionTitle, + setSessionContextUsage, + setPendingPermission, + setPendingQuestion, + setTodos, + setLatestDiagnostic, + setOpenCodeReady, + setInitialPromptSent, + setUserPromptMessageId, + setProjectPrompt, + setPresenceLoaded, + setItems, + scrollToBottom, +}: UseChatHistoryOptions) { + const [sessionTitleLoaded, setSessionTitleLoaded] = useState( + () => sessionTitle !== null, + ); + const [sessionContextLoaded, setSessionContextLoaded] = useState(false); + const loadingHistoryRef = useRef(false); + const configLoadedRef = useRef(false); + + const toErrorMessage = useCallback((error: unknown): string => { + if (error instanceof ChatApiError) { + return ( + error.body?.message || + error.body?.error || + error.body?.title || + error.message + ); + } + if (error instanceof Error) { + return error.message; + } + return String(error); + }, []); + + const fetchJson = useCallback( + async (url: string, init?: RequestInit): Promise => { + const response = await fetch(url, init); + if (response.status === 204) { + return {} as T; + } + + const contentType = response.headers.get("content-type") ?? ""; + const hasJson = contentType.includes("application/json"); + const body = hasJson + ? ((await response.json()) as ProxyErrorPayload | T) + : null; + + if (!response.ok) { + throw new ChatApiError( + `Request failed with status ${response.status}`, + response.status, + (body as ProxyErrorPayload | null) ?? null, + ); + } + + return (body as T) ?? ({} as T); + }, + [], + ); + + const showRequestError = useCallback( + (title: string, error: unknown) => { + const message = toErrorMessage(error); + toast.error(title, { description: message }); + setLatestDiagnostic({ + timestamp: new Date().toISOString(), + source: "unknown", + category: "unknown", + title, + message, + technicalDetails: undefined, + remediation: [], + isRetryable: true, + }); + }, + [setLatestDiagnostic, toErrorMessage], + ); + + const refreshBlockingState = useCallback( + async (activeSessionId: string) => { + try { + const [permissions, questions, sessionTodos] = await Promise.all([ + fetchJson< + Array<{ + id: string; + sessionID: string; + permission: string; + patterns: string[]; + tool?: { messageID: string; callID: string }; + }> + >(`/api/projects/${projectId}/opencode/permission`), + fetchJson< + Array<{ + id: string; + sessionID: string; + questions: Array<{ + header: string; + question: string; + options: Array<{ label: string; description: string }>; + multiple?: boolean; + custom?: boolean; + }>; + tool?: { messageID: string; callID: string }; + }> + >(`/api/projects/${projectId}/opencode/question`), + fetchJson< + Array<{ content: string; status: string; priority: string }> + >( + `/api/projects/${projectId}/opencode/session/${activeSessionId}/todo`, + ), + ]); + + const permission = permissions.find( + (request) => request.sessionID === activeSessionId, + ); + const question = questions.find( + (request) => request.sessionID === activeSessionId, + ); + + setPendingPermission( + permission + ? { + requestId: permission.id, + sessionId: permission.sessionID, + permission: permission.permission, + patterns: permission.patterns, + ...(permission.tool?.messageID + ? { messageId: permission.tool.messageID } + : {}), + ...(permission.tool?.callID + ? { toolCallId: permission.tool.callID } + : {}), + } + : null, + ); + + setPendingQuestion( + question + ? { + requestId: question.id, + sessionId: question.sessionID, + questions: question.questions, + ...(question.tool?.messageID + ? { messageId: question.tool.messageID } + : {}), + ...(question.tool?.callID + ? { toolCallId: question.tool.callID } + : {}), + } + : null, + ); + + setTodos(Array.isArray(sessionTodos) ? sessionTodos : []); + } catch { + // The stream will eventually sync this state. Ignore best-effort hydration errors. + } + }, + [fetchJson, projectId, setPendingPermission, setPendingQuestion, setTodos], + ); + + useEffect(() => { + if (!sessionId) return; + + const loadSessionMetadata = async () => { + try { + const [session, messagesData] = await Promise.all([ + fetchJson<{ title?: string | null }>( + `/api/projects/${projectId}/opencode/session/${sessionId}`, + ), + fetchJson( + `/api/projects/${projectId}/opencode/session/${sessionId}/message?limit=${CHAT_HISTORY_PAGE_LIMIT}`, + ), + ]); + setSessionTitle(session.title ?? null); + const messages = Array.isArray(messagesData) + ? messagesData + : (messagesData.messages ?? []); + setSessionContextUsage( + getSessionContextUsage(messages, currentModel, models), + ); + } catch { + // Best-effort only. The chat works fine without this metadata. + } finally { + setSessionTitleLoaded(true); + setSessionContextLoaded(true); + } + }; + + void loadSessionMetadata(); + }, [ + fetchJson, + projectId, + sessionId, + setSessionContextUsage, + setSessionTitle, + currentModel, + models, + ]); + + // Load model from OpenCode config + useEffect(() => { + if (!opencodeReady || configLoadedRef.current) return; + + const loadConfig = async () => { + try { + configLoadedRef.current = true; + const config = await fetchJson<{ model?: string }>( + `/api/projects/${projectId}/opencode/config`, + ); + if (config.model) { + const parts = config.model.split("/"); + const providerID = parts[0]; + if (parts.length >= 2 && providerID) { + setCurrentModel({ + providerID, + modelID: parts.slice(1).join("/"), + }); + } + } + } catch (error) { + showRequestError("Failed to load model config", error); + } + }; + loadConfig(); + }, [fetchJson, opencodeReady, projectId, setCurrentModel, showRequestError]); + + // Load history + useEffect(() => { + if ( + !opencodeReady || + historyLoaded || + loadingHistoryRef.current || + !presenceLoaded + ) + return; + + const loadHistory = async () => { + loadingHistoryRef.current = true; + try { + // Resolve session id without an extra roundtrip when possible. + // liveData hands us bootstrapSessionId; fall back to the list call only + // for legacy projects that predate that field. + let latestSessionId = sessionId ?? liveData?.bootstrapSessionId ?? null; + + if (!latestSessionId) { + type SessionListItem = { id?: string } | string; + const sessionsData = await fetchJson< + SessionListItem[] | { sessions?: SessionListItem[] } + >(`/api/projects/${projectId}/opencode/session`); + const sessions = Array.isArray(sessionsData) + ? sessionsData + : sessionsData.sessions || []; + const latest = sessions[sessions.length - 1]; + latestSessionId = + (typeof latest === "string" ? latest : latest?.id) ?? null; + } + + if (!latestSessionId) { + setHistoryLoaded(true); + loadingHistoryRef.current = false; + return; + } + setSessionId(latestSessionId); + + // Run the three remaining requests in parallel — none of them depend + // on each other, so the perceived latency drops to the slowest one. + const [, sessionInfoResult, messagesResult] = await Promise.allSettled([ + refreshBlockingState(latestSessionId), + fetchJson<{ revert?: { messageID?: string } }>( + `/api/projects/${projectId}/opencode/session/${latestSessionId}`, + ), + fetchJson( + `/api/projects/${projectId}/opencode/session/${latestSessionId}/message?limit=${CHAT_HISTORY_PAGE_LIMIT}`, + ), + ]); + + if (sessionInfoResult.status === "fulfilled") { + setRevertMessageId( + sessionInfoResult.value?.revert?.messageID ?? null, + ); + } + + if (messagesResult.status !== "fulfilled") { + if (messagesResult.status === "rejected") { + showRequestError( + "Failed to load chat history", + messagesResult.reason, + ); + } + setHistoryLoaded(true); + loadingHistoryRef.current = false; + return; + } + + const messagesData = messagesResult.value; + const messages = Array.isArray(messagesData) + ? messagesData + : (messagesData.messages ?? []); + + const lastUserMessageWithModel = messages + .filter( + (message) => message.info?.role === "user" && message.info.model, + ) + .pop(); + + const resolvedModel = lastUserMessageWithModel?.info?.model + ? normalizeMessageModel(lastUserMessageWithModel.info.model, models) + : null; + + if (resolvedModel) { + setCurrentModel(resolvedModel); + } + setSessionContextUsage( + getSessionContextUsage(messages, resolvedModel, models), + ); + setSessionContextLoaded(true); + + const historyItems = buildHistoryItems( + messages, + userPromptMessageId ?? null, + ); + if (historyItems.length > 0) { + setItems(historyItems); + setTimeout(scrollToBottom, 100); + } + } catch (error) { + showRequestError("Failed to load chat history", error); + } + loadingHistoryRef.current = false; + setHistoryLoaded(true); + }; + loadHistory(); + }, [ + projectId, + opencodeReady, + historyLoaded, + presenceLoaded, + userPromptMessageId, + models, + sessionId, + liveData?.bootstrapSessionId, + setHistoryLoaded, + setSessionId, + setCurrentModel, + fetchJson, + refreshBlockingState, + setRevertMessageId, + setItems, + scrollToBottom, + showRequestError, + setSessionContextUsage, + ]); + + // React to live state for readiness + useEffect(() => { + if (!liveData || opencodeReady) return; + if (liveData.opencodeReady) { + setOpenCodeReady(true); + setInitialPromptSent(liveData.initialPromptSent); + setUserPromptMessageId(liveData.userPromptMessageId); + setProjectPrompt(liveData.prompt ?? ""); + if (liveData.bootstrapSessionId) + setSessionId(liveData.bootstrapSessionId); + setPresenceLoaded(true); + } + }, [ + liveData, + opencodeReady, + setOpenCodeReady, + setInitialPromptSent, + setUserPromptMessageId, + setProjectPrompt, + setSessionId, + setPresenceLoaded, + ]); + + return { + fetchJson, + toErrorMessage, + showRequestError, + refreshBlockingState, + sessionTitleLoaded, + sessionContextLoaded, + setSessionContextLoaded, + }; +} diff --git a/src/hooks/useChatPanel.ts b/src/hooks/useChatPanel.ts index a55ee86..92bd5eb 100644 --- a/src/hooks/useChatPanel.ts +++ b/src/hooks/useChatPanel.ts @@ -1,71 +1,14 @@ -import { actions } from "astro:actions"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { toast } from "sonner"; +import { useEffect } from "react"; +import { useChatActions } from "@/hooks/useChatActions"; +import { useChatHistory } from "@/hooks/useChatHistory"; +import { useChatRestore } from "@/hooks/useChatRestore"; +import { useChatScroll } from "@/hooks/useChatScroll"; +import { useChatSend } from "@/hooks/useChatSend"; +import { useChatSse } from "@/hooks/useChatSse"; import { useLiveState } from "@/hooks/useLiveState"; -import { promptAttachmentToPromptParts } from "@/lib/chat/attachmentPromptText"; -import { - buildHistoryItems, - type RawSessionMessage, -} from "@/lib/chat/buildHistoryItems"; -import { getSessionContextUsage } from "@/lib/chat/sessionContextUsage"; import type { InitialChatState } from "@/server/opencode/initialChat"; - -const CHAT_HISTORY_PAGE_LIMIT = 50; - import { useChatStore } from "@/stores/useChatStore"; -import { - createErrorPart, - createPromptAttachmentPart, - createTextPart, - type Message, - type MessagePart, - type PromptAttachmentPart, -} from "@/types/message"; - -const MAX_SSE_RECONNECT_DELAY_MS = 10_000; -const MAX_SSE_RECONNECT_ATTEMPTS = 12; -const SSE_INACTIVITY_TIMEOUT_MS = 45_000; - -interface ProxyErrorPayload { - message?: string; - error?: string; - title?: string; - category?: string; - source?: string; -} - -class ChatApiError extends Error { - status: number; - body: ProxyErrorPayload | null; - - constructor(message: string, status: number, body: ProxyErrorPayload | null) { - super(message); - this.name = "ChatApiError"; - this.status = status; - this.body = body; - } -} - -/** - * Normalize model ID from OpenCode messages by matching against available models. - */ -function normalizeMessageModel( - model: { providerID: string; modelID: string }, - availableModels: ReadonlyArray<{ id: string; provider: string }>, -): { providerID: string; modelID: string } { - if (model.modelID.includes("/")) return model; - if (model.providerID !== "opencode") return model; - - const matchingModel = availableModels.find( - (m) => - m.provider === "opencode" && - (m.id === model.modelID || m.id.endsWith(`/${model.modelID}`)), - ); - - return matchingModel - ? { providerID: model.providerID, modelID: matchingModel.id } - : model; -} +import { createTextPart, type Message } from "@/types/message"; interface UseChatPanelOptions { projectId: string; @@ -140,420 +83,52 @@ export function useChatPanel({ setTodos, } = store; - const [expandedTools, setExpandedTools] = useState>(new Set()); - const [shouldAutoScroll, setShouldAutoScroll] = useState(true); - const [sessionTitleLoaded, setSessionTitleLoaded] = useState( - () => sessionTitle !== null, - ); - const [sessionContextLoaded, setSessionContextLoaded] = useState(false); - const [restoreEnabled, setRestoreEnabled] = useState(true); - const [restoreGuardLoaded, setRestoreGuardLoaded] = useState(false); - const [draftSeed, setDraftSeed] = useState<{ - key: number; - text: string; - attachments: PromptAttachmentPart[]; - } | null>(null); - const scrollRef = useRef(null); - const eventSourceRef = useRef(null); - const reconnectTimeoutRef = useRef | null>( - null, - ); - const inactivityTimeoutRef = useRef | null>( - null, - ); - const reconnectAttemptsRef = useRef(0); // Live state from SSE — replaces presence heartbeat polling const { data: liveData } = useLiveState(`/api/projects/${projectId}/live`); - const loadingHistoryRef = useRef(false); - const configLoadedRef = useRef(false); - const streamDegradedRef = useRef(false); // initialChat is consumed by ProjectContentWrapper via useChatStoreSeed — // it lands in the store before this hook's first read. Reference it here // only to silence the unused-prop lint when callers pass it through. void initialChat; - const isNearBottom = useCallback(() => { - if (!scrollRef.current) return false; - const { scrollTop, scrollHeight, clientHeight } = scrollRef.current; - return scrollHeight - (scrollTop + clientHeight) < 100; - }, []); - - const scrollToBottom = useCallback(() => { - if (scrollRef.current && shouldAutoScroll) { - setTimeout(() => { - if (scrollRef.current) { - scrollRef.current.scrollTop = scrollRef.current.scrollHeight; - } - }, 0); - } - }, [shouldAutoScroll]); - - const handleScroll = useCallback(() => { - setShouldAutoScroll(isNearBottom()); - }, [isNearBottom]); - - useEffect(() => { - if (items.length > 0 && shouldAutoScroll && scrollRef.current) { - scrollRef.current.scrollTop = scrollRef.current.scrollHeight; - } - }, [shouldAutoScroll, items]); - - const toErrorMessage = useCallback((error: unknown): string => { - if (error instanceof ChatApiError) { - return ( - error.body?.message || - error.body?.error || - error.body?.title || - error.message - ); - } - if (error instanceof Error) { - return error.message; - } - return String(error); - }, []); - - const fetchJson = useCallback( - async (url: string, init?: RequestInit): Promise => { - const response = await fetch(url, init); - if (response.status === 204) { - return {} as T; - } - - const contentType = response.headers.get("content-type") ?? ""; - const hasJson = contentType.includes("application/json"); - const body = hasJson - ? ((await response.json()) as ProxyErrorPayload | T) - : null; - - if (!response.ok) { - throw new ChatApiError( - `Request failed with status ${response.status}`, - response.status, - (body as ProxyErrorPayload | null) ?? null, - ); - } - - return (body as T) ?? ({} as T); - }, - [], - ); - - const showRequestError = useCallback( - (title: string, error: unknown) => { - const message = toErrorMessage(error); - toast.error(title, { description: message }); - setLatestDiagnostic({ - timestamp: new Date().toISOString(), - source: "unknown", - category: "unknown", - title, - message, - technicalDetails: undefined, - remediation: [], - isRetryable: true, - }); - }, - [setLatestDiagnostic, toErrorMessage], - ); - - const refreshBlockingState = useCallback( - async (activeSessionId: string) => { - try { - const [permissions, questions, sessionTodos] = await Promise.all([ - fetchJson< - Array<{ - id: string; - sessionID: string; - permission: string; - patterns: string[]; - tool?: { messageID: string; callID: string }; - }> - >(`/api/projects/${projectId}/opencode/permission`), - fetchJson< - Array<{ - id: string; - sessionID: string; - questions: Array<{ - header: string; - question: string; - options: Array<{ label: string; description: string }>; - multiple?: boolean; - custom?: boolean; - }>; - tool?: { messageID: string; callID: string }; - }> - >(`/api/projects/${projectId}/opencode/question`), - fetchJson< - Array<{ content: string; status: string; priority: string }> - >( - `/api/projects/${projectId}/opencode/session/${activeSessionId}/todo`, - ), - ]); - - const permission = permissions.find( - (request) => request.sessionID === activeSessionId, - ); - const question = questions.find( - (request) => request.sessionID === activeSessionId, - ); - - setPendingPermission( - permission - ? { - requestId: permission.id, - sessionId: permission.sessionID, - permission: permission.permission, - patterns: permission.patterns, - ...(permission.tool?.messageID - ? { messageId: permission.tool.messageID } - : {}), - ...(permission.tool?.callID - ? { toolCallId: permission.tool.callID } - : {}), - } - : null, - ); - - setPendingQuestion( - question - ? { - requestId: question.id, - sessionId: question.sessionID, - questions: question.questions, - ...(question.tool?.messageID - ? { messageId: question.tool.messageID } - : {}), - ...(question.tool?.callID - ? { toolCallId: question.tool.callID } - : {}), - } - : null, - ); + const { scrollRef, scrollToBottom, handleScroll } = useChatScroll(items); - setTodos(Array.isArray(sessionTodos) ? sessionTodos : []); - } catch { - // The stream will eventually sync this state. Ignore best-effort hydration errors. - } - }, - [fetchJson, projectId, setPendingPermission, setPendingQuestion, setTodos], - ); - - useEffect(() => { - if (!sessionId) return; - - const loadSessionMetadata = async () => { - try { - const [session, messagesData] = await Promise.all([ - fetchJson<{ title?: string | null }>( - `/api/projects/${projectId}/opencode/session/${sessionId}`, - ), - fetchJson( - `/api/projects/${projectId}/opencode/session/${sessionId}/message?limit=${CHAT_HISTORY_PAGE_LIMIT}`, - ), - ]); - setSessionTitle(session.title ?? null); - const messages = Array.isArray(messagesData) - ? messagesData - : (messagesData.messages ?? []); - setSessionContextUsage( - getSessionContextUsage(messages, currentModel, models), - ); - } catch { - // Best-effort only. The chat works fine without this metadata. - } finally { - setSessionTitleLoaded(true); - setSessionContextLoaded(true); - } - }; - - void loadSessionMetadata(); - }, [ + const { fetchJson, + toErrorMessage, + showRequestError, + refreshBlockingState, + sessionTitleLoaded, + sessionContextLoaded, + setSessionContextLoaded, + } = useChatHistory({ projectId, - sessionId, - setSessionContextUsage, - setSessionTitle, - currentModel, models, - ]); - - // Load model from OpenCode config - useEffect(() => { - if (!opencodeReady || configLoadedRef.current) return; - - const loadConfig = async () => { - try { - configLoadedRef.current = true; - const config = await fetchJson<{ model?: string }>( - `/api/projects/${projectId}/opencode/config`, - ); - if (config.model) { - const parts = config.model.split("/"); - const providerID = parts[0]; - if (parts.length >= 2 && providerID) { - setCurrentModel({ - providerID, - modelID: parts.slice(1).join("/"), - }); - } - } - } catch (error) { - showRequestError("Failed to load model config", error); - } - }; - loadConfig(); - }, [fetchJson, opencodeReady, projectId, setCurrentModel, showRequestError]); - - // Load history - useEffect(() => { - if ( - !opencodeReady || - historyLoaded || - loadingHistoryRef.current || - !presenceLoaded - ) - return; - - const loadHistory = async () => { - loadingHistoryRef.current = true; - try { - // Resolve session id without an extra roundtrip when possible. - // liveData hands us bootstrapSessionId; fall back to the list call only - // for legacy projects that predate that field. - let latestSessionId = sessionId ?? liveData?.bootstrapSessionId ?? null; - - if (!latestSessionId) { - type SessionListItem = { id?: string } | string; - const sessionsData = await fetchJson< - SessionListItem[] | { sessions?: SessionListItem[] } - >(`/api/projects/${projectId}/opencode/session`); - const sessions = Array.isArray(sessionsData) - ? sessionsData - : sessionsData.sessions || []; - const latest = sessions[sessions.length - 1]; - latestSessionId = - (typeof latest === "string" ? latest : latest?.id) ?? null; - } - - if (!latestSessionId) { - setHistoryLoaded(true); - loadingHistoryRef.current = false; - return; - } - setSessionId(latestSessionId); - - // Run the three remaining requests in parallel — none of them depend - // on each other, so the perceived latency drops to the slowest one. - const [, sessionInfoResult, messagesResult] = await Promise.allSettled([ - refreshBlockingState(latestSessionId), - fetchJson<{ revert?: { messageID?: string } }>( - `/api/projects/${projectId}/opencode/session/${latestSessionId}`, - ), - fetchJson( - `/api/projects/${projectId}/opencode/session/${latestSessionId}/message?limit=${CHAT_HISTORY_PAGE_LIMIT}`, - ), - ]); - - if (sessionInfoResult.status === "fulfilled") { - setRevertMessageId( - sessionInfoResult.value?.revert?.messageID ?? null, - ); - } - - if (messagesResult.status !== "fulfilled") { - if (messagesResult.status === "rejected") { - showRequestError( - "Failed to load chat history", - messagesResult.reason, - ); - } - setHistoryLoaded(true); - loadingHistoryRef.current = false; - return; - } - - const messagesData = messagesResult.value; - const messages = Array.isArray(messagesData) - ? messagesData - : (messagesData.messages ?? []); - - const lastUserMessageWithModel = messages - .filter( - (message) => message.info?.role === "user" && message.info.model, - ) - .pop(); - - const resolvedModel = lastUserMessageWithModel?.info?.model - ? normalizeMessageModel(lastUserMessageWithModel.info.model, models) - : null; - - if (resolvedModel) { - setCurrentModel(resolvedModel); - } - setSessionContextUsage( - getSessionContextUsage(messages, resolvedModel, models), - ); - setSessionContextLoaded(true); - - const historyItems = buildHistoryItems( - messages, - userPromptMessageId ?? null, - ); - if (historyItems.length > 0) { - setItems(historyItems); - setTimeout(scrollToBottom, 100); - } - } catch (error) { - showRequestError("Failed to load chat history", error); - } - loadingHistoryRef.current = false; - setHistoryLoaded(true); - }; - loadHistory(); - }, [ - projectId, + sessionId, opencodeReady, historyLoaded, presenceLoaded, userPromptMessageId, - models, - sessionId, - liveData?.bootstrapSessionId, - setHistoryLoaded, + sessionTitle, + currentModel, + liveData, setSessionId, - setCurrentModel, - fetchJson, - refreshBlockingState, setRevertMessageId, - setItems, - scrollToBottom, - showRequestError, + setCurrentModel, + setHistoryLoaded, + setSessionTitle, setSessionContextUsage, - ]); - - // React to live state for readiness - useEffect(() => { - if (!liveData || opencodeReady) return; - if (liveData.opencodeReady) { - setOpenCodeReady(true); - setInitialPromptSent(liveData.initialPromptSent); - setUserPromptMessageId(liveData.userPromptMessageId); - setProjectPrompt(liveData.prompt ?? ""); - if (liveData.bootstrapSessionId) - setSessionId(liveData.bootstrapSessionId); - setPresenceLoaded(true); - } - }, [ - liveData, - opencodeReady, + setPendingPermission, + setPendingQuestion, + setTodos, + setLatestDiagnostic, setOpenCodeReady, setInitialPromptSent, setUserPromptMessageId, setProjectPrompt, - setSessionId, setPresenceLoaded, - ]); + setItems, + scrollToBottom, + }); // Load initial prompt useEffect(() => { @@ -581,115 +156,13 @@ export function useChatPanel({ setInitialPromptSent, ]); - // SSE events - useEffect(() => { - if (!opencodeReady) return; - - const clearInactivityTimer = () => { - if (inactivityTimeoutRef.current) { - clearTimeout(inactivityTimeoutRef.current); - inactivityTimeoutRef.current = null; - } - }; - - const scheduleInactivityTimeout = () => { - clearInactivityTimer(); - inactivityTimeoutRef.current = setTimeout(() => { - setIsStreaming(false); - if (!streamDegradedRef.current) { - streamDegradedRef.current = true; - toast.warning("Connection looks unstable", { - description: - "The assistant stream stalled. Reconnecting automatically...", - }); - } - }, SSE_INACTIVITY_TIMEOUT_MS); - }; - - const connect = () => { - const eventSource = new EventSource( - `/api/projects/${projectId}/opencode/event`, - ); - eventSourceRef.current = eventSource; - scheduleInactivityTimeout(); - - const handler = (e: Event) => { - try { - scheduleInactivityTimeout(); - handleChatEvent(JSON.parse((e as MessageEvent).data)); - streamDegradedRef.current = false; - } catch (error) { - showRequestError("Failed to process live event", error); - } - }; - - eventSource.addEventListener("chat.event", handler); - eventSource.onopen = () => { - reconnectAttemptsRef.current = 0; - streamDegradedRef.current = false; - scheduleInactivityTimeout(); - }; - - eventSource.onerror = () => { - clearInactivityTimer(); - if (eventSourceRef.current === eventSource) { - eventSource.close(); - eventSourceRef.current = null; - } - - if (reconnectTimeoutRef.current) { - clearTimeout(reconnectTimeoutRef.current); - } - - const attempt = reconnectAttemptsRef.current; - const delay = Math.min( - MAX_SSE_RECONNECT_DELAY_MS, - 1_000 * 2 ** attempt + Math.floor(Math.random() * 250), - ); - reconnectAttemptsRef.current = attempt + 1; - - if (reconnectAttemptsRef.current > MAX_SSE_RECONNECT_ATTEMPTS) { - setIsStreaming(false); - if (!streamDegradedRef.current) { - streamDegradedRef.current = true; - toast.error("Live updates disconnected", { - description: - "Please refresh the page if reconnection does not recover shortly.", - }); - } - } - - reconnectTimeoutRef.current = setTimeout(() => { - if (opencodeReady) { - connect(); - } - }, delay); - }; - - return () => { - clearInactivityTimer(); - eventSource.removeEventListener("chat.event", handler); - eventSource.close(); - }; - }; - - const cleanup = connect(); - return () => { - cleanup(); - clearInactivityTimer(); - if (reconnectTimeoutRef.current) { - clearTimeout(reconnectTimeoutRef.current); - reconnectTimeoutRef.current = null; - } - eventSourceRef.current = null; - }; - }, [ - handleChatEvent, - opencodeReady, + useChatSse({ projectId, + opencodeReady, + handleChatEvent, setIsStreaming, showRequestError, - ]); + }); // Streaming state change useEffect(() => { @@ -700,399 +173,71 @@ export function useChatPanel({ onStreamingStateChange?.(userMessageCount, isStreaming); }, [items, isStreaming, onStreamingStateChange]); - const handleSend = async ( - content: string, - attachments?: PromptAttachmentPart[], - ) => { - const messageParts: MessagePart[] = []; - if (attachments) { - for (const attachment of attachments) { - messageParts.push( - createPromptAttachmentPart({ - filename: attachment.filename, - mime: attachment.mime, - kind: attachment.kind, - ...(attachment.dataUrl ? { dataUrl: attachment.dataUrl } : {}), - ...(attachment.size !== undefined ? { size: attachment.size } : {}), - ...(attachment.textPreview - ? { textPreview: attachment.textPreview } - : {}), - ...(attachment.textContent - ? { textContent: attachment.textContent } - : {}), - id: attachment.id, - }), - ); - } - } - if (content) messageParts.push(createTextPart(content)); - - const userMessageId = `user_${Date.now()}`; - addItem({ - type: "message", - id: userMessageId, - data: { - id: userMessageId, - role: "user", - parts: messageParts, - localStatus: "pending", - }, - }); - scrollToBottom(); - - try { - let currentSessionId = sessionId; - if (!currentSessionId) { - const data = await fetchJson<{ id: string }>( - `/api/projects/${projectId}/opencode/session`, - { - method: "POST", - }, - ); - currentSessionId = data.id; - setSessionId(currentSessionId); - await refreshBlockingState(currentSessionId); - } - if (!currentSessionId) { - throw new Error("Session could not be created"); - } - - type ApiPromptPart = - | { type: "text"; text: string } - | { - type: "file"; - mime: string; - url: string; - filename: string; - }; - - const apiParts: ApiPromptPart[] = []; - if (content) apiParts.push({ type: "text", text: content }); - if (attachments) { - for (const attachment of attachments) { - apiParts.push(...promptAttachmentToPromptParts(attachment)); - } - } - - await fetchJson( - `/api/projects/${projectId}/opencode/session/${currentSessionId}/prompt_async`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - parts: apiParts, - ...(currentModel && { model: currentModel }), - }), - }, - ); - updateItem(userMessageId, { localStatus: "sent" }); - setIsStreaming(true); - setPendingAttachments([]); - setPendingAttachmentError(null); - // Sending a new prompt branches from the revert point; the visible-items - // slice should no longer hide the new message + its response. - if (revertMessageId) setRevertMessageId(null); - } catch (error) { - updateItem(userMessageId, { - localStatus: "failed", - localError: toErrorMessage(error), - parts: [ - ...messageParts, - createErrorPart("Message failed to send", toErrorMessage(error)), - ], - }); - showRequestError("Failed to send message", error); - } - }; - - const handleStop = useCallback(async () => { - if (!sessionId || !isStreaming) return; - - try { - await fetchJson( - `/api/projects/${projectId}/opencode/session/${sessionId}/abort`, - { method: "POST" }, - ); - } catch (error) { - showRequestError("Failed to stop response", error); - } - }, [fetchJson, isStreaming, projectId, sessionId, showRequestError]); - - const handleCompact = useCallback(async () => { - if (!sessionId) return; - if (!currentModel) { - toast.info("Select a model before compacting the conversation"); - return; - } - - setIsStreaming(true); - setSessionContextLoaded(false); - try { - await fetchJson( - `/api/projects/${projectId}/opencode/session/${sessionId}/summarize`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - providerID: currentModel.providerID, - modelID: currentModel.modelID, - auto: false, - }), - }, - ); - - const messagesData = await fetchJson< - RawSessionMessage[] | { messages?: RawSessionMessage[] } - >( - `/api/projects/${projectId}/opencode/session/${sessionId}/message?limit=${CHAT_HISTORY_PAGE_LIMIT}`, - ); - const messages = Array.isArray(messagesData) - ? messagesData - : (messagesData.messages ?? []); - setSessionContextUsage( - getSessionContextUsage(messages, currentModel, models), - ); - toast.success("Conversation compacted"); - } catch (error) { - showRequestError("Failed to compact conversation", error); - } finally { - setIsStreaming(false); - setSessionContextLoaded(true); - } - }, [ + const { handleSend } = useChatSend({ + projectId, + sessionId, currentModel, + revertMessageId, + addItem, + updateItem, + scrollToBottom, fetchJson, - models, + setSessionId, + refreshBlockingState, + setIsStreaming, + setPendingAttachments, + setPendingAttachmentError, + setRevertMessageId, + toErrorMessage, + showRequestError, + }); + + const { + expandedTools, + handleStop, + handleCompact, + handleModelChange, + handlePermissionDecision, + handleQuestionSubmit, + handleQuestionReject, + toggleToolExpanded, + } = useChatActions({ projectId, + models, sessionId, + isStreaming, + currentModel, + pendingAttachments, + pendingPermission, + pendingQuestion, + fetchJson, + showRequestError, setIsStreaming, + setSessionContextLoaded, setSessionContextUsage, - showRequestError, - ]); - - const handleModelChange = async (compositeKey: string) => { - const [providerId, ...modelIdParts] = compositeKey.split(":"); - const modelId = modelIdParts.join(":"); - const newModelConfig = models.find( - (m) => m.id === modelId && m.provider === providerId, - ); - const newModelSupportsAttachments = - newModelConfig?.supportsAttachments ?? true; - - if (pendingAttachments.length > 0 && !newModelSupportsAttachments) { - const textAttachments = pendingAttachments.filter( - (a) => a.kind !== "image", - ); - const imageAttachments = pendingAttachments.filter( - (a) => a.kind === "image", - ); - if (imageAttachments.length > 0) { - setPendingAttachments(textAttachments); - setPendingAttachmentError(null); - toast.info("Images cleared", { - description: "The selected model doesn't support image input", - }); - } - } - - const newModel = newModelConfig - ? { providerID: newModelConfig.provider, modelID: newModelConfig.id } - : null; - const previousModel = currentModel; - setCurrentModel(newModel); - - try { - const modelString = newModel - ? `${newModel.providerID}/${newModel.modelID}` - : null; - const result = await actions.projects.updateModel({ - projectId, - model: modelString || "", - }); - if (!result.data?.success) setCurrentModel(previousModel); - } catch { - setCurrentModel(previousModel); - } - }; - - const handlePermissionDecision = useCallback( - async (reply: "once" | "always" | "reject") => { - if (!pendingPermission) return; - - try { - await fetchJson( - `/api/projects/${projectId}/opencode/permission/${pendingPermission.requestId}/reply`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ reply }), - }, - ); - setPendingPermission(null); - } catch (error) { - showRequestError("Failed to respond to permission request", error); - } - }, - [ - fetchJson, - pendingPermission, - projectId, - setPendingPermission, - showRequestError, - ], - ); - - const handleQuestionSubmit = useCallback( - async (answers: string[][]) => { - if (!pendingQuestion) return; - - try { - await fetchJson( - `/api/projects/${projectId}/opencode/question/${pendingQuestion.requestId}/reply`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ answers }), - }, - ); - setPendingQuestion(null); - } catch (error) { - showRequestError("Failed to submit question response", error); - } - }, - [ - fetchJson, - pendingQuestion, - projectId, - setPendingQuestion, - showRequestError, - ], - ); - - const handleQuestionReject = useCallback(async () => { - if (!pendingQuestion) return; + setCurrentModel, + setPendingAttachments, + setPendingAttachmentError, + setPendingPermission, + setPendingQuestion, + }); - try { - await fetchJson( - `/api/projects/${projectId}/opencode/question/${pendingQuestion.requestId}/reject`, - { - method: "POST", - }, - ); - setPendingQuestion(null); - } catch (error) { - showRequestError("Failed to reject question", error); - } - }, [ - fetchJson, - pendingQuestion, + const { + restoreEnabled, + restoreGuardLoaded, + draftSeed, + handleRestore, + handleUnrevert, + clearDraftSeed, + visibleItems, + } = useChatRestore({ projectId, - setPendingQuestion, + sessionId, + items, + revertMessageId, + setRevertMessageId, showRequestError, - ]); - - const toggleToolExpanded = (id: string) => { - setExpandedTools((prev) => { - const next = new Set(prev); - if (next.has(id)) next.delete(id); - else next.add(id); - return next; - }); - }; - - // Fetch restore safety status when session is available - useEffect(() => { - if (!sessionId) { - setRestoreEnabled(false); - setRestoreGuardLoaded(true); - return; - } - let cancelled = false; - void (async () => { - try { - const result = await actions.chat.getRestoreStatus({ projectId }); - if (cancelled) return; - if (result.error) { - setRestoreEnabled(false); - } else { - setRestoreEnabled(result.data?.canRestore ?? false); - } - } catch (_error) { - if (cancelled) return; - setRestoreEnabled(false); - } finally { - if (!cancelled) setRestoreGuardLoaded(true); - } - })(); - return () => { - cancelled = true; - }; - }, [sessionId, projectId]); - - const handleRestore = useCallback( - async ({ - messageId, - role, - text, - attachments, - }: { - messageId: string; - role: "user" | "assistant"; - text: string; - attachments: PromptAttachmentPart[]; - }) => { - // Optimistic: snapshot prev state, apply, roll back on failure. - const prevRevert = revertMessageId; - setRevertMessageId(messageId); - if (role === "user") { - setDraftSeed({ key: Date.now(), text, attachments }); - } - try { - const result = await actions.chat.revertToMessage({ - projectId, - messageId, - }); - if (result.error) { - throw new Error(result.error.message); - } - // Trust server-returned revert pointer. - const serverId = result.data?.revertMessageId ?? messageId; - if (serverId !== messageId) setRevertMessageId(serverId); - toast.success("Conversation restored"); - } catch (error) { - setRevertMessageId(prevRevert); - if (role === "user") setDraftSeed(null); - showRequestError("Failed to restore conversation", error); - throw error; - } - }, - [projectId, revertMessageId, setRevertMessageId, showRequestError], - ); - - const handleUnrevert = useCallback(async () => { - const prevRevert = revertMessageId; - if (!prevRevert) return; - setRevertMessageId(null); - try { - const result = await actions.chat.unrevertSession({ projectId }); - if (result.error) throw new Error(result.error.message); - } catch (error) { - setRevertMessageId(prevRevert); - showRequestError("Failed to cancel restore", error); - throw error; - } - }, [projectId, revertMessageId, setRevertMessageId, showRequestError]); - - const clearDraftSeed = useCallback(() => setDraftSeed(null), []); - - const visibleItems = useMemo(() => { - if (!revertMessageId) return items; - const boundary = items.findIndex( - (item) => item.type === "message" && item.id === revertMessageId, - ); - if (boundary < 0) return items; - return items.slice(0, boundary); - }, [items, revertMessageId]); + }); return { items: visibleItems, diff --git a/src/hooks/useChatRestore.ts b/src/hooks/useChatRestore.ts new file mode 100644 index 0000000..a6812ee --- /dev/null +++ b/src/hooks/useChatRestore.ts @@ -0,0 +1,134 @@ +import { actions } from "astro:actions"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; +import type { PromptAttachmentPart } from "@/types/message"; + +interface UseChatRestoreOptions { + projectId: string; + sessionId: string | null; + items: TItem[]; + revertMessageId: string | null; + setRevertMessageId: (messageId: string | null) => void; + showRequestError: (title: string, error: unknown) => void; +} + +export function useChatRestore({ + projectId, + sessionId, + items, + revertMessageId, + setRevertMessageId, + showRequestError, +}: UseChatRestoreOptions) { + const [restoreEnabled, setRestoreEnabled] = useState(true); + const [restoreGuardLoaded, setRestoreGuardLoaded] = useState(false); + const [draftSeed, setDraftSeed] = useState<{ + key: number; + text: string; + attachments: PromptAttachmentPart[]; + } | null>(null); + + // Fetch restore safety status when session is available + useEffect(() => { + if (!sessionId) { + setRestoreEnabled(false); + setRestoreGuardLoaded(true); + return; + } + let cancelled = false; + void (async () => { + try { + const result = await actions.chat.getRestoreStatus({ projectId }); + if (cancelled) return; + if (result.error) { + setRestoreEnabled(false); + } else { + setRestoreEnabled(result.data?.canRestore ?? false); + } + } catch (_error) { + if (cancelled) return; + setRestoreEnabled(false); + } finally { + if (!cancelled) setRestoreGuardLoaded(true); + } + })(); + return () => { + cancelled = true; + }; + }, [sessionId, projectId]); + + const handleRestore = useCallback( + async ({ + messageId, + role, + text, + attachments, + }: { + messageId: string; + role: "user" | "assistant"; + text: string; + attachments: PromptAttachmentPart[]; + }) => { + // Optimistic: snapshot prev state, apply, roll back on failure. + const prevRevert = revertMessageId; + setRevertMessageId(messageId); + if (role === "user") { + setDraftSeed({ key: Date.now(), text, attachments }); + } + try { + const result = await actions.chat.revertToMessage({ + projectId, + messageId, + }); + if (result.error) { + throw new Error(result.error.message); + } + // Trust server-returned revert pointer. + const serverId = result.data?.revertMessageId ?? messageId; + if (serverId !== messageId) setRevertMessageId(serverId); + toast.success("Conversation restored"); + } catch (error) { + setRevertMessageId(prevRevert); + if (role === "user") setDraftSeed(null); + showRequestError("Failed to restore conversation", error); + throw error; + } + }, + [projectId, revertMessageId, setRevertMessageId, showRequestError], + ); + + const handleUnrevert = useCallback(async () => { + const prevRevert = revertMessageId; + if (!prevRevert) return; + setRevertMessageId(null); + try { + const result = await actions.chat.unrevertSession({ projectId }); + if (result.error) throw new Error(result.error.message); + } catch (error) { + setRevertMessageId(prevRevert); + showRequestError("Failed to cancel restore", error); + throw error; + } + }, [projectId, revertMessageId, setRevertMessageId, showRequestError]); + + const clearDraftSeed = useCallback(() => setDraftSeed(null), []); + + const visibleItems = useMemo(() => { + if (!revertMessageId) return items; + const boundary = items.findIndex( + (item) => item.type === "message" && item.id === revertMessageId, + ); + if (boundary < 0) return items; + return items.slice(0, boundary); + }, [items, revertMessageId]); + + return { + restoreEnabled, + restoreGuardLoaded, + draftSeed, + handleRestore, + handleUnrevert, + clearDraftSeed, + visibleItems, + }; +} diff --git a/src/hooks/useChatScroll.ts b/src/hooks/useChatScroll.ts new file mode 100644 index 0000000..a59ded7 --- /dev/null +++ b/src/hooks/useChatScroll.ts @@ -0,0 +1,40 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +export function useChatScroll(items: ReadonlyArray) { + const [shouldAutoScroll, setShouldAutoScroll] = useState(true); + const scrollRef = useRef(null); + + const isNearBottom = useCallback(() => { + if (!scrollRef.current) return false; + const { scrollTop, scrollHeight, clientHeight } = scrollRef.current; + return scrollHeight - (scrollTop + clientHeight) < 100; + }, []); + + const scrollToBottom = useCallback(() => { + if (scrollRef.current && shouldAutoScroll) { + setTimeout(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }, 0); + } + }, [shouldAutoScroll]); + + const handleScroll = useCallback(() => { + setShouldAutoScroll(isNearBottom()); + }, [isNearBottom]); + + useEffect(() => { + if (items.length > 0 && shouldAutoScroll && scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }, [shouldAutoScroll, items]); + + return { + scrollRef, + shouldAutoScroll, + isNearBottom, + scrollToBottom, + handleScroll, + }; +} diff --git a/src/hooks/useChatSend.ts b/src/hooks/useChatSend.ts new file mode 100644 index 0000000..40a6359 --- /dev/null +++ b/src/hooks/useChatSend.ts @@ -0,0 +1,163 @@ +import { promptAttachmentToPromptParts } from "@/lib/chat/attachmentPromptText"; +import type { ChatItem } from "@/stores/useChatStore"; +import { + createErrorPart, + createPromptAttachmentPart, + createTextPart, + type MessagePart, + type PromptAttachmentPart, +} from "@/types/message"; + +interface UseChatSendOptions { + projectId: string; + sessionId: string | null; + currentModel: { providerID: string; modelID: string } | null; + revertMessageId: string | null; + addItem: (item: { + type: "message"; + id: string; + data: { + id: string; + role: "user"; + parts: MessagePart[]; + localStatus: "pending"; + }; + }) => void; + updateItem: (id: string, data: Partial) => void; + scrollToBottom: () => void; + fetchJson: (url: string, init?: RequestInit) => Promise; + setSessionId: (sessionId: string | null) => void; + refreshBlockingState: (activeSessionId: string) => Promise; + setIsStreaming: (value: boolean) => void; + setPendingAttachments: (attachments: PromptAttachmentPart[]) => void; + setPendingAttachmentError: (error: string | null) => void; + setRevertMessageId: (messageId: string | null) => void; + toErrorMessage: (error: unknown) => string; + showRequestError: (title: string, error: unknown) => void; +} + +export function useChatSend({ + projectId, + sessionId, + currentModel, + revertMessageId, + addItem, + updateItem, + scrollToBottom, + fetchJson, + setSessionId, + refreshBlockingState, + setIsStreaming, + setPendingAttachments, + setPendingAttachmentError, + setRevertMessageId, + toErrorMessage, + showRequestError, +}: UseChatSendOptions) { + const handleSend = async ( + content: string, + attachments?: PromptAttachmentPart[], + ) => { + const messageParts: MessagePart[] = []; + if (attachments) { + for (const attachment of attachments) { + messageParts.push( + createPromptAttachmentPart({ + filename: attachment.filename, + mime: attachment.mime, + kind: attachment.kind, + ...(attachment.dataUrl ? { dataUrl: attachment.dataUrl } : {}), + ...(attachment.size !== undefined ? { size: attachment.size } : {}), + ...(attachment.textPreview + ? { textPreview: attachment.textPreview } + : {}), + ...(attachment.textContent + ? { textContent: attachment.textContent } + : {}), + id: attachment.id, + }), + ); + } + } + if (content) messageParts.push(createTextPart(content)); + + const userMessageId = `user_${Date.now()}`; + addItem({ + type: "message", + id: userMessageId, + data: { + id: userMessageId, + role: "user", + parts: messageParts, + localStatus: "pending", + }, + }); + scrollToBottom(); + + try { + let currentSessionId = sessionId; + if (!currentSessionId) { + const data = await fetchJson<{ id: string }>( + `/api/projects/${projectId}/opencode/session`, + { + method: "POST", + }, + ); + currentSessionId = data.id; + setSessionId(currentSessionId); + await refreshBlockingState(currentSessionId); + } + if (!currentSessionId) { + throw new Error("Session could not be created"); + } + + type ApiPromptPart = + | { type: "text"; text: string } + | { + type: "file"; + mime: string; + url: string; + filename: string; + }; + + const apiParts: ApiPromptPart[] = []; + if (content) apiParts.push({ type: "text", text: content }); + if (attachments) { + for (const attachment of attachments) { + apiParts.push(...promptAttachmentToPromptParts(attachment)); + } + } + + await fetchJson( + `/api/projects/${projectId}/opencode/session/${currentSessionId}/prompt_async`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + parts: apiParts, + ...(currentModel && { model: currentModel }), + }), + }, + ); + updateItem(userMessageId, { localStatus: "sent" }); + setIsStreaming(true); + setPendingAttachments([]); + setPendingAttachmentError(null); + // Sending a new prompt branches from the revert point; the visible-items + // slice should no longer hide the new message + its response. + if (revertMessageId) setRevertMessageId(null); + } catch (error) { + updateItem(userMessageId, { + localStatus: "failed", + localError: toErrorMessage(error), + parts: [ + ...messageParts, + createErrorPart("Message failed to send", toErrorMessage(error)), + ], + }); + showRequestError("Failed to send message", error); + } + }; + + return { handleSend }; +} diff --git a/src/hooks/useChatSse.ts b/src/hooks/useChatSse.ts new file mode 100644 index 0000000..4d94b65 --- /dev/null +++ b/src/hooks/useChatSse.ts @@ -0,0 +1,143 @@ +import { useEffect, useRef } from "react"; +import { toast } from "sonner"; +import type { ChatStore } from "@/stores/useChatStore"; + +const MAX_SSE_RECONNECT_DELAY_MS = 10_000; +const MAX_SSE_RECONNECT_ATTEMPTS = 12; +const SSE_INACTIVITY_TIMEOUT_MS = 45_000; + +interface UseChatSseOptions { + projectId: string; + opencodeReady: boolean; + handleChatEvent: ChatStore["handleChatEvent"]; + setIsStreaming: (value: boolean) => void; + showRequestError: (title: string, error: unknown) => void; +} + +export function useChatSse({ + projectId, + opencodeReady, + handleChatEvent, + setIsStreaming, + showRequestError, +}: UseChatSseOptions) { + const eventSourceRef = useRef(null); + const reconnectTimeoutRef = useRef | null>( + null, + ); + const inactivityTimeoutRef = useRef | null>( + null, + ); + const reconnectAttemptsRef = useRef(0); + const streamDegradedRef = useRef(false); + + // SSE events + useEffect(() => { + if (!opencodeReady) return; + + const clearInactivityTimer = () => { + if (inactivityTimeoutRef.current) { + clearTimeout(inactivityTimeoutRef.current); + inactivityTimeoutRef.current = null; + } + }; + + const scheduleInactivityTimeout = () => { + clearInactivityTimer(); + inactivityTimeoutRef.current = setTimeout(() => { + setIsStreaming(false); + if (!streamDegradedRef.current) { + streamDegradedRef.current = true; + toast.warning("Connection looks unstable", { + description: + "The assistant stream stalled. Reconnecting automatically...", + }); + } + }, SSE_INACTIVITY_TIMEOUT_MS); + }; + + const connect = () => { + const eventSource = new EventSource( + `/api/projects/${projectId}/opencode/event`, + ); + eventSourceRef.current = eventSource; + scheduleInactivityTimeout(); + + const handler = (e: Event) => { + try { + scheduleInactivityTimeout(); + handleChatEvent(JSON.parse((e as MessageEvent).data)); + streamDegradedRef.current = false; + } catch (error) { + showRequestError("Failed to process live event", error); + } + }; + + eventSource.addEventListener("chat.event", handler); + eventSource.onopen = () => { + reconnectAttemptsRef.current = 0; + streamDegradedRef.current = false; + scheduleInactivityTimeout(); + }; + + eventSource.onerror = () => { + clearInactivityTimer(); + if (eventSourceRef.current === eventSource) { + eventSource.close(); + eventSourceRef.current = null; + } + + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + } + + const attempt = reconnectAttemptsRef.current; + const delay = Math.min( + MAX_SSE_RECONNECT_DELAY_MS, + 1_000 * 2 ** attempt + Math.floor(Math.random() * 250), + ); + reconnectAttemptsRef.current = attempt + 1; + + if (reconnectAttemptsRef.current > MAX_SSE_RECONNECT_ATTEMPTS) { + setIsStreaming(false); + if (!streamDegradedRef.current) { + streamDegradedRef.current = true; + toast.error("Live updates disconnected", { + description: + "Please refresh the page if reconnection does not recover shortly.", + }); + } + } + + reconnectTimeoutRef.current = setTimeout(() => { + if (opencodeReady) { + connect(); + } + }, delay); + }; + + return () => { + clearInactivityTimer(); + eventSource.removeEventListener("chat.event", handler); + eventSource.close(); + }; + }; + + const cleanup = connect(); + return () => { + cleanup(); + clearInactivityTimer(); + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; + } + eventSourceRef.current = null; + }; + }, [ + handleChatEvent, + opencodeReady, + projectId, + setIsStreaming, + showRequestError, + ]); +} diff --git a/src/stores/useChatStore.interactionHandlers.ts b/src/stores/useChatStore.interactionHandlers.ts new file mode 100644 index 0000000..0edee30 --- /dev/null +++ b/src/stores/useChatStore.interactionHandlers.ts @@ -0,0 +1,42 @@ +import type { + ChatStore, + PendingPermissionRequest, + PendingQuestionRequest, + TodoItem, +} from "./useChatStore"; + +type ChatStoreSet = ( + partial: Partial | ((state: ChatStore) => Partial), +) => void; + +export function handlePermissionRequested( + set: ChatStoreSet, + payload: Record, +) { + const request = payload as unknown as PendingPermissionRequest; + set({ pendingPermission: request }); +} + +export function handlePermissionResolved(set: ChatStoreSet) { + set({ pendingPermission: null }); +} + +export function handleQuestionRequested( + set: ChatStoreSet, + payload: Record, +) { + const request = payload as unknown as PendingQuestionRequest; + set({ pendingQuestion: request }); +} + +export function handleQuestionResolved(set: ChatStoreSet) { + set({ pendingQuestion: null }); +} + +export function handleTodoUpdated( + set: ChatStoreSet, + payload: Record, +) { + const todoPayload = payload as { todos: TodoItem[] }; + set({ todos: todoPayload.todos ?? [] }); +} diff --git a/src/stores/useChatStore.messageHandlers.ts b/src/stores/useChatStore.messageHandlers.ts new file mode 100644 index 0000000..0d58047 --- /dev/null +++ b/src/stores/useChatStore.messageHandlers.ts @@ -0,0 +1,242 @@ +import { createErrorPart, createTextPart, type Message } from "@/types/message"; +import type { ChatItem, ChatStore } from "./useChatStore"; + +type ChatStoreSet = ( + partial: Partial | ((state: ChatStore) => Partial), +) => void; + +export function handleMessageUser( + set: ChatStoreSet, + payload: Record, +) { + const { messageId } = payload as { messageId: string }; + set((state) => ({ + items: state.items.filter( + (item) => + !( + item.type === "message" && + item.id === messageId && + (item.data as Message).role === "assistant" + ), + ), + })); +} + +export function handleMessagePartAdded( + set: ChatStoreSet, + payload: Record, +) { + const { messageId, partId, partType, deltaText, text } = payload as { + messageId: string; + partId: string; + partType: string; + deltaText?: string; + text?: string; + }; + + set((state) => { + const existing = state.items.find( + (item) => item.type === "message" && item.id === messageId, + ); + + if (existing && existing.type === "message") { + const msg = existing.data as Message; + const textPartIdx = msg.parts.findIndex((p) => p.type === "text"); + + if (textPartIdx !== -1 && (deltaText || text)) { + // Replace if full text available, otherwise append delta + const updatedParts = [...msg.parts]; + const part = updatedParts[textPartIdx]; + if (part && part.type === "text") { + if (typeof text === "string") { + part.text = text; + } else if (deltaText) { + part.text = part.text + deltaText; + } + } + return { + items: state.items.map((item) => + item.id === messageId + ? { + ...item, + data: { + ...msg, + parts: updatedParts, + isStreaming: true, + }, + } + : item, + ), + isStreaming: true, + }; + } else if (deltaText || text) { + // Create new text part + const partText = text ?? deltaText ?? ""; + const updatedParts = [...msg.parts, createTextPart(partText, partId)]; + return { + items: state.items.map((item) => + item.id === messageId + ? { + ...item, + data: { + ...msg, + parts: updatedParts, + isStreaming: true, + }, + } + : item, + ), + isStreaming: true, + }; + } + } else if (partType === "text" && (deltaText || text)) { + // New message + const partText = text ?? deltaText ?? ""; + const newItem: ChatItem = { + type: "message", + id: messageId, + data: { + id: messageId, + role: "assistant", + parts: [createTextPart(partText, partId)], + isStreaming: true, + }, + }; + return { + items: [...state.items, newItem], + isStreaming: true, + }; + } + + return { isStreaming: true }; + }); +} + +export function handleMessageDelta( + set: ChatStoreSet, + payload: Record, +) { + // Backward compatibility: handle old-style delta events + const { messageId, deltaText } = payload as { + messageId: string; + deltaText: string; + }; + + set((state) => { + const existing = state.items.find( + (item) => item.type === "message" && item.id === messageId, + ); + + if (existing && existing.type === "message") { + const msg = existing.data as Message; + const textPart = msg.parts[msg.parts.length - 1]; + + if (textPart && textPart.type === "text") { + // Append to existing text part + const updatedParts = [...msg.parts]; + const lastPart = updatedParts[updatedParts.length - 1]; + if (lastPart && lastPart.type === "text") { + lastPart.text = textPart.text + deltaText; + } + return { + items: state.items.map((item) => + item.id === messageId + ? { + ...item, + data: { + ...msg, + parts: updatedParts, + isStreaming: true, + }, + } + : item, + ), + isStreaming: true, + }; + } else { + // Create new text part + const updatedParts = [...msg.parts, createTextPart(deltaText)]; + return { + items: state.items.map((item) => + item.id === messageId + ? { + ...item, + data: { + ...msg, + parts: updatedParts, + isStreaming: true, + }, + } + : item, + ), + isStreaming: true, + }; + } + } else { + // New message + const newItem: ChatItem = { + type: "message", + id: messageId, + data: { + id: messageId, + role: "assistant", + parts: [createTextPart(deltaText)], + isStreaming: true, + }, + }; + return { + items: [...state.items, newItem], + isStreaming: true, + }; + } + }); +} + +export function handleMessageFinal( + set: ChatStoreSet, + payload: Record, +) { + const { messageId, error } = payload as { + messageId: string; + error?: { message: string; details?: unknown }; + }; + + set((state) => { + const items = state.items.map((item) => { + if (item.type === "message" && item.id === messageId) { + const msg = item.data as Message; + const hasErrorPart = msg.parts.some( + (part) => + part.type === "error" && error && part.message === error.message, + ); + const nextParts = + error && !hasErrorPart + ? [ + ...msg.parts, + createErrorPart( + error.message, + typeof error.details === "string" ? error.details : undefined, + ), + ] + : msg.parts; + const nextMessage: Message = { + ...msg, + parts: nextParts, + isStreaming: false, + localStatus: "sent", + ...(error ? { localError: error.message } : {}), + }; + return { + ...item, + data: nextMessage, + }; + } + return item; + }); + + return { + items, + isStreaming: false, + }; + }); +} diff --git a/src/stores/useChatStore.sessionHandlers.ts b/src/stores/useChatStore.sessionHandlers.ts new file mode 100644 index 0000000..f08e830 --- /dev/null +++ b/src/stores/useChatStore.sessionHandlers.ts @@ -0,0 +1,33 @@ +import type { ChatStore } from "./useChatStore"; + +type ChatStoreSet = ( + partial: Partial | ((state: ChatStore) => Partial), +) => void; + +export function handleSessionUpdated( + set: ChatStoreSet, + payload: Record, + eventSessionId?: string, +) { + const { title } = payload as { title?: string | null }; + set((state) => { + if ( + eventSessionId && + state.sessionId && + eventSessionId !== state.sessionId + ) { + return {}; + } + return { sessionTitle: title ?? null }; + }); +} + +export function handleSessionStatus( + set: ChatStoreSet, + payload: Record, +) { + const { status } = payload as { status: string }; + if (status === "completed" || status === "idle") { + set({ isStreaming: false }); + } +} diff --git a/src/stores/useChatStore.toolHandlers.ts b/src/stores/useChatStore.toolHandlers.ts new file mode 100644 index 0000000..ef9dd0a --- /dev/null +++ b/src/stores/useChatStore.toolHandlers.ts @@ -0,0 +1,78 @@ +import type { OpencodeDiagnostic } from "@/server/opencode/diagnostics"; +import type { ChatItem, ChatStore, ToolCall } from "./useChatStore"; + +type ChatStoreSet = ( + partial: Partial | ((state: ChatStore) => Partial), +) => void; + +export function handleReasoningPart() { + // Reasoning parts handled as visual elements + // Can be enhanced later to nest within messages +} + +export function handleToolUpdate( + set: ChatStoreSet, + payload: Record, +) { + const { toolCallId, name, input, status, output, error } = payload as { + toolCallId: string; + name: string; + input?: unknown; + status: "running" | "success" | "error"; + output?: unknown; + error?: unknown; + }; + + set((state) => { + const existingIdx = state.items.findIndex( + (item) => item.type === "tool" && item.id === toolCallId, + ); + + if (existingIdx !== -1) { + // Update existing tool + const items = [...state.items]; + const toolItem = items[existingIdx]; + if (toolItem && toolItem.type === "tool") { + items[existingIdx] = { + type: "tool", + id: toolItem.id, + data: { + ...(toolItem.data as ToolCall), + input, + output, + error, + status, + }, + }; + } + return { items }; + } else { + // Create new tool item + const newItem: ChatItem = { + type: "tool", + id: toolCallId, + data: { + id: toolCallId, + name, + input, + output, + error, + status, + }, + }; + return { items: [...state.items, newItem] }; + } + }); +} + +export function handleDiagnostic( + set: ChatStoreSet, + payload: Record, +) { + const { diagnostic } = payload as { diagnostic: OpencodeDiagnostic }; + set((state) => ({ + latestDiagnostic: diagnostic, + diagnosticHistory: [...state.diagnosticHistory, diagnostic], + isStreaming: false, + })); +} diff --git a/src/stores/useChatStore.ts b/src/stores/useChatStore.ts index 5603e52..faf7233 100644 --- a/src/stores/useChatStore.ts +++ b/src/stores/useChatStore.ts @@ -1,11 +1,28 @@ import { create } from "zustand"; import type { OpencodeDiagnostic } from "@/server/opencode/diagnostics"; +import type { Message, PromptAttachmentPart } from "@/types/message"; import { - createErrorPart, - createTextPart, - type Message, - type PromptAttachmentPart, -} from "@/types/message"; + handlePermissionRequested, + handlePermissionResolved, + handleQuestionRequested, + handleQuestionResolved, + handleTodoUpdated, +} from "./useChatStore.interactionHandlers"; +import { + handleMessageDelta, + handleMessageFinal, + handleMessagePartAdded, + handleMessageUser, +} from "./useChatStore.messageHandlers"; +import { + handleSessionStatus, + handleSessionUpdated, +} from "./useChatStore.sessionHandlers"; +import { + handleDiagnostic, + handleReasoningPart, + handleToolUpdate, +} from "./useChatStore.toolHandlers"; /** * ToolCall type (moved from ToolCallDisplay for reuse) @@ -246,358 +263,72 @@ export function createChatStore() { switch (type) { case "chat.session.updated": { - const { title } = payload as { title?: string | null }; - set((state) => { - if ( - eventSessionId && - state.sessionId && - eventSessionId !== state.sessionId - ) { - return {}; - } - return { sessionTitle: title ?? null }; - }); + handleSessionUpdated(set, payload, eventSessionId); break; } case "chat.message.user": { - const { messageId } = payload as { messageId: string }; - set((state) => ({ - items: state.items.filter( - (item) => - !( - item.type === "message" && - item.id === messageId && - (item.data as Message).role === "assistant" - ), - ), - })); + handleMessageUser(set, payload); break; } case "chat.message.part.added": { - const { messageId, partId, partType, deltaText, text } = payload as { - messageId: string; - partId: string; - partType: string; - deltaText?: string; - text?: string; - }; - - set((state) => { - const existing = state.items.find( - (item) => item.type === "message" && item.id === messageId, - ); - - if (existing && existing.type === "message") { - const msg = existing.data as Message; - const textPartIdx = msg.parts.findIndex((p) => p.type === "text"); - - if (textPartIdx !== -1 && (deltaText || text)) { - // Replace if full text available, otherwise append delta - const updatedParts = [...msg.parts]; - const part = updatedParts[textPartIdx]; - if (part && part.type === "text") { - if (typeof text === "string") { - part.text = text; - } else if (deltaText) { - part.text = part.text + deltaText; - } - } - return { - items: state.items.map((item) => - item.id === messageId - ? { - ...item, - data: { - ...msg, - parts: updatedParts, - isStreaming: true, - }, - } - : item, - ), - isStreaming: true, - }; - } else if (deltaText || text) { - // Create new text part - const partText = text ?? deltaText ?? ""; - const updatedParts = [ - ...msg.parts, - createTextPart(partText, partId), - ]; - return { - items: state.items.map((item) => - item.id === messageId - ? { - ...item, - data: { - ...msg, - parts: updatedParts, - isStreaming: true, - }, - } - : item, - ), - isStreaming: true, - }; - } - } else if (partType === "text" && (deltaText || text)) { - // New message - const partText = text ?? deltaText ?? ""; - const newItem: ChatItem = { - type: "message", - id: messageId, - data: { - id: messageId, - role: "assistant", - parts: [createTextPart(partText, partId)], - isStreaming: true, - }, - }; - return { - items: [...state.items, newItem], - isStreaming: true, - }; - } - - return { isStreaming: true }; - }); + handleMessagePartAdded(set, payload); break; } case "chat.message.delta": { - // Backward compatibility: handle old-style delta events - const { messageId, deltaText } = payload as { - messageId: string; - deltaText: string; - }; - - set((state) => { - const existing = state.items.find( - (item) => item.type === "message" && item.id === messageId, - ); - - if (existing && existing.type === "message") { - const msg = existing.data as Message; - const textPart = msg.parts[msg.parts.length - 1]; - - if (textPart && textPart.type === "text") { - // Append to existing text part - const updatedParts = [...msg.parts]; - const lastPart = updatedParts[updatedParts.length - 1]; - if (lastPart && lastPart.type === "text") { - lastPart.text = textPart.text + deltaText; - } - return { - items: state.items.map((item) => - item.id === messageId - ? { - ...item, - data: { - ...msg, - parts: updatedParts, - isStreaming: true, - }, - } - : item, - ), - isStreaming: true, - }; - } else { - // Create new text part - const updatedParts = [...msg.parts, createTextPart(deltaText)]; - return { - items: state.items.map((item) => - item.id === messageId - ? { - ...item, - data: { - ...msg, - parts: updatedParts, - isStreaming: true, - }, - } - : item, - ), - isStreaming: true, - }; - } - } else { - // New message - const newItem: ChatItem = { - type: "message", - id: messageId, - data: { - id: messageId, - role: "assistant", - parts: [createTextPart(deltaText)], - isStreaming: true, - }, - }; - return { - items: [...state.items, newItem], - isStreaming: true, - }; - } - }); + handleMessageDelta(set, payload); break; } case "chat.message.final": { - const { messageId, error } = payload as { - messageId: string; - error?: { message: string; details?: unknown }; - }; - - set((state) => { - const items = state.items.map((item) => { - if (item.type === "message" && item.id === messageId) { - const msg = item.data as Message; - const hasErrorPart = msg.parts.some( - (part) => - part.type === "error" && - error && - part.message === error.message, - ); - const nextParts = - error && !hasErrorPart - ? [ - ...msg.parts, - createErrorPart( - error.message, - typeof error.details === "string" - ? error.details - : undefined, - ), - ] - : msg.parts; - const nextMessage: Message = { - ...msg, - parts: nextParts, - isStreaming: false, - localStatus: "sent", - ...(error ? { localError: error.message } : {}), - }; - return { - ...item, - data: nextMessage, - }; - } - return item; - }); - - return { - items, - isStreaming: false, - }; - }); + handleMessageFinal(set, payload); break; } case "chat.permission.requested": { - const request = payload as unknown as PendingPermissionRequest; - set({ pendingPermission: request }); + handlePermissionRequested(set, payload); break; } case "chat.permission.resolved": { - set({ pendingPermission: null }); + handlePermissionResolved(set); break; } case "chat.question.requested": { - const request = payload as unknown as PendingQuestionRequest; - set({ pendingQuestion: request }); + handleQuestionRequested(set, payload); break; } case "chat.question.resolved": { - set({ pendingQuestion: null }); + handleQuestionResolved(set); break; } case "chat.todo.updated": { - const todoPayload = payload as { todos: TodoItem[] }; - set({ todos: todoPayload.todos ?? [] }); + handleTodoUpdated(set, payload); break; } case "chat.reasoning.part": { - // Reasoning parts handled as visual elements - // Can be enhanced later to nest within messages + handleReasoningPart(); break; } case "chat.tool.update": { - const { toolCallId, name, input, status, output, error } = - payload as { - toolCallId: string; - name: string; - input?: unknown; - status: "running" | "success" | "error"; - output?: unknown; - error?: unknown; - }; - - set((state) => { - const existingIdx = state.items.findIndex( - (item) => item.type === "tool" && item.id === toolCallId, - ); - - if (existingIdx !== -1) { - // Update existing tool - const items = [...state.items]; - const toolItem = items[existingIdx]; - if (toolItem && toolItem.type === "tool") { - items[existingIdx] = { - type: "tool", - id: toolItem.id, - data: { - ...(toolItem.data as ToolCall), - input, - output, - error, - status, - }, - }; - } - return { items }; - } else { - // Create new tool item - const newItem: ChatItem = { - type: "tool", - id: toolCallId, - data: { - id: toolCallId, - name, - input, - output, - error, - status, - }, - }; - return { items: [...state.items, newItem] }; - } - }); + handleToolUpdate(set, payload); break; } case "chat.session.status": { - const { status } = payload as { status: string }; - if (status === "completed" || status === "idle") { - set({ isStreaming: false }); - } + handleSessionStatus(set, payload); break; } case "chat.diagnostic": { - const { diagnostic } = payload as { diagnostic: OpencodeDiagnostic }; - set((state) => ({ - latestDiagnostic: diagnostic, - diagnosticHistory: [...state.diagnosticHistory, diagnostic], - isStreaming: false, - })); + handleDiagnostic(set, payload); break; } } From 05555c6095e80db1340788ac56e9ae55de799660 Mon Sep 17 00:00:00 2001 From: Pablo P Varela Date: Fri, 12 Jun 2026 18:27:04 +0200 Subject: [PATCH 2/2] fix: address CodeRabbit review findings (#68) - Validate attachments JSON in project create (throw BAD_REQUEST) - Enqueue before status change in restart/deploy (avoid stranded state) - Restore attachments on model-change failure in useChatActions - Seed sessionContextLoaded from hydrated sessionTitle - Consistent ordering between local message parts and API payload - Halt SSE reconnect after max attempts exceeded - Respect partId when updating text parts in message handlers - Set localStatus: 'failed' when message final carries an error - Merge tool update fields instead of overwriting with undefined - Type interaction handlers with concrete payload types --- src/actions/projects.create.ts | 17 ++++++++-- src/actions/projects.lifecycle.ts | 4 +-- src/actions/projects.production.ts | 8 ++--- src/hooks/useChatActions.ts | 8 ++++- src/hooks/useChatHistory.ts | 4 ++- src/hooks/useChatSend.ts | 34 +++++++++---------- src/hooks/useChatSse.ts | 1 + .../useChatStore.interactionHandlers.ts | 11 +++--- src/stores/useChatStore.messageHandlers.ts | 6 ++-- src/stores/useChatStore.toolHandlers.ts | 12 ++++--- 10 files changed, 63 insertions(+), 42 deletions(-) diff --git a/src/actions/projects.create.ts b/src/actions/projects.create.ts index 0bc1970..a7d84b1 100644 --- a/src/actions/projects.create.ts +++ b/src/actions/projects.create.ts @@ -40,8 +40,21 @@ export const create = defineAction({ | undefined; if (input.attachments) { try { - attachments = JSON.parse(input.attachments); - } catch {} + const parsed = JSON.parse(input.attachments); + if (!Array.isArray(parsed)) { + throw new ActionError({ + code: "BAD_REQUEST", + message: "Invalid attachments format", + }); + } + attachments = parsed; + } catch (err) { + if (err instanceof ActionError) throw err; + throw new ActionError({ + code: "BAD_REQUEST", + message: "Invalid attachments JSON", + }); + } } const projectId = randomBytes(12).toString("hex"); diff --git a/src/actions/projects.lifecycle.ts b/src/actions/projects.lifecycle.ts index 9fc6d99..8960c41 100644 --- a/src/actions/projects.lifecycle.ts +++ b/src/actions/projects.lifecycle.ts @@ -71,13 +71,13 @@ export const restart = defineAction({ "@/server/projects/projects.model" ); - await updateProjectStatus(input.projectId, "starting"); - const job = await enqueueDockerEnsureRunning({ projectId: input.projectId, reason: "user", }); + await updateProjectStatus(input.projectId, "starting"); + return { success: true, jobId: job.id }; }, }); diff --git a/src/actions/projects.production.ts b/src/actions/projects.production.ts index da31d53..4fbf9f6 100644 --- a/src/actions/projects.production.ts +++ b/src/actions/projects.production.ts @@ -55,14 +55,14 @@ export const deploy = defineAction({ }); } - await updateProductionStatus(input.projectId, "queued", { - productionError: null, - }); - const job = await enqueueProductionBuild({ projectId: input.projectId, }); + await updateProductionStatus(input.projectId, "queued", { + productionError: null, + }); + return { success: true, jobId: job.id }; }, }); diff --git a/src/hooks/useChatActions.ts b/src/hooks/useChatActions.ts index c6f859e..a6fe769 100644 --- a/src/hooks/useChatActions.ts +++ b/src/hooks/useChatActions.ts @@ -134,6 +134,8 @@ export function useChatActions({ const newModelSupportsAttachments = newModelConfig?.supportsAttachments ?? true; + const previousAttachments = pendingAttachments; + if (pendingAttachments.length > 0 && !newModelSupportsAttachments) { const textAttachments = pendingAttachments.filter( (a) => a.kind !== "image", @@ -164,9 +166,13 @@ export function useChatActions({ projectId, model: modelString || "", }); - if (!result.data?.success) setCurrentModel(previousModel); + if (!result.data?.success) { + setCurrentModel(previousModel); + setPendingAttachments(previousAttachments); + } } catch { setCurrentModel(previousModel); + setPendingAttachments(previousAttachments); } }; diff --git a/src/hooks/useChatHistory.ts b/src/hooks/useChatHistory.ts index 3317c19..7733add 100644 --- a/src/hooks/useChatHistory.ts +++ b/src/hooks/useChatHistory.ts @@ -143,7 +143,9 @@ export function useChatHistory({ const [sessionTitleLoaded, setSessionTitleLoaded] = useState( () => sessionTitle !== null, ); - const [sessionContextLoaded, setSessionContextLoaded] = useState(false); + const [sessionContextLoaded, setSessionContextLoaded] = useState( + () => sessionTitle !== null, + ); const loadingHistoryRef = useRef(false); const configLoadedRef = useRef(false); diff --git a/src/hooks/useChatSend.ts b/src/hooks/useChatSend.ts index 40a6359..d1a6bf9 100644 --- a/src/hooks/useChatSend.ts +++ b/src/hooks/useChatSend.ts @@ -58,7 +58,18 @@ export function useChatSend({ content: string, attachments?: PromptAttachmentPart[], ) => { + // Build local message parts and API payload from a single ordered source const messageParts: MessagePart[] = []; + type ApiPromptPart = + | { type: "text"; text: string } + | { + type: "file"; + mime: string; + url: string; + filename: string; + }; + const apiParts: ApiPromptPart[] = []; + if (attachments) { for (const attachment of attachments) { messageParts.push( @@ -77,9 +88,13 @@ export function useChatSend({ id: attachment.id, }), ); + apiParts.push(...promptAttachmentToPromptParts(attachment)); } } - if (content) messageParts.push(createTextPart(content)); + if (content) { + messageParts.push(createTextPart(content)); + apiParts.push({ type: "text", text: content }); + } const userMessageId = `user_${Date.now()}`; addItem({ @@ -111,23 +126,6 @@ export function useChatSend({ throw new Error("Session could not be created"); } - type ApiPromptPart = - | { type: "text"; text: string } - | { - type: "file"; - mime: string; - url: string; - filename: string; - }; - - const apiParts: ApiPromptPart[] = []; - if (content) apiParts.push({ type: "text", text: content }); - if (attachments) { - for (const attachment of attachments) { - apiParts.push(...promptAttachmentToPromptParts(attachment)); - } - } - await fetchJson( `/api/projects/${projectId}/opencode/session/${currentSessionId}/prompt_async`, { diff --git a/src/hooks/useChatSse.ts b/src/hooks/useChatSse.ts index 4d94b65..ccf587a 100644 --- a/src/hooks/useChatSse.ts +++ b/src/hooks/useChatSse.ts @@ -107,6 +107,7 @@ export function useChatSse({ "Please refresh the page if reconnection does not recover shortly.", }); } + return; } reconnectTimeoutRef.current = setTimeout(() => { diff --git a/src/stores/useChatStore.interactionHandlers.ts b/src/stores/useChatStore.interactionHandlers.ts index 0edee30..6d370f9 100644 --- a/src/stores/useChatStore.interactionHandlers.ts +++ b/src/stores/useChatStore.interactionHandlers.ts @@ -11,9 +11,8 @@ type ChatStoreSet = ( export function handlePermissionRequested( set: ChatStoreSet, - payload: Record, + request: PendingPermissionRequest, ) { - const request = payload as unknown as PendingPermissionRequest; set({ pendingPermission: request }); } @@ -23,9 +22,8 @@ export function handlePermissionResolved(set: ChatStoreSet) { export function handleQuestionRequested( set: ChatStoreSet, - payload: Record, + request: PendingQuestionRequest, ) { - const request = payload as unknown as PendingQuestionRequest; set({ pendingQuestion: request }); } @@ -35,8 +33,7 @@ export function handleQuestionResolved(set: ChatStoreSet) { export function handleTodoUpdated( set: ChatStoreSet, - payload: Record, + payload: { todos: TodoItem[] }, ) { - const todoPayload = payload as { todos: TodoItem[] }; - set({ todos: todoPayload.todos ?? [] }); + set({ todos: payload.todos ?? [] }); } diff --git a/src/stores/useChatStore.messageHandlers.ts b/src/stores/useChatStore.messageHandlers.ts index 0d58047..6cc00f8 100644 --- a/src/stores/useChatStore.messageHandlers.ts +++ b/src/stores/useChatStore.messageHandlers.ts @@ -41,7 +41,9 @@ export function handleMessagePartAdded( if (existing && existing.type === "message") { const msg = existing.data as Message; - const textPartIdx = msg.parts.findIndex((p) => p.type === "text"); + const textPartIdx = msg.parts.findIndex( + (p) => p.type === "text" && (partId ? p.id === partId : true), + ); if (textPartIdx !== -1 && (deltaText || text)) { // Replace if full text available, otherwise append delta @@ -223,7 +225,7 @@ export function handleMessageFinal( ...msg, parts: nextParts, isStreaming: false, - localStatus: "sent", + localStatus: error ? "failed" : "sent", ...(error ? { localError: error.message } : {}), }; return { diff --git a/src/stores/useChatStore.toolHandlers.ts b/src/stores/useChatStore.toolHandlers.ts index ef9dd0a..61ee12f 100644 --- a/src/stores/useChatStore.toolHandlers.ts +++ b/src/stores/useChatStore.toolHandlers.ts @@ -29,18 +29,20 @@ export function handleToolUpdate( ); if (existingIdx !== -1) { - // Update existing tool + // Update existing tool — merge fields so undefined payload values + // don't overwrite previously received input/output/error const items = [...state.items]; const toolItem = items[existingIdx]; if (toolItem && toolItem.type === "tool") { + const existing = toolItem.data as ToolCall; items[existingIdx] = { type: "tool", id: toolItem.id, data: { - ...(toolItem.data as ToolCall), - input, - output, - error, + ...existing, + ...(input !== undefined ? { input } : {}), + ...(output !== undefined ? { output } : {}), + ...(error !== undefined ? { error } : {}), status, }, };