fix(app): add friendly not-found handling

This commit is contained in:
Gabriel Brown
2026-07-10 17:24:32 -04:00
parent c4d0f5219a
commit ed21126eea
7 changed files with 203 additions and 5 deletions
+30
View File
@@ -0,0 +1,30 @@
'use client';
import { useEffect } from 'react';
import * as Sentry from '@sentry/nextjs';
import { Button } from '@spoon/ui';
const AppError = ({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) => {
useEffect(() => {
Sentry.captureException(error);
}, [error]);
return (
<main className='flex min-h-[50vh] flex-col items-center justify-center gap-4 p-6 text-center'>
<h1 className='text-2xl font-semibold'>Something went wrong</h1>
<p className='text-muted-foreground max-w-md text-sm'>
This page hit an unexpected error. It has been reported.
</p>
<Button onClick={() => reset()}>Try again</Button>
</main>
);
};
export default AppError;
+17
View File
@@ -0,0 +1,17 @@
import Link from 'next/link';
import { Button } from '@spoon/ui';
const NotFound = () => (
<main className='flex min-h-[50vh] flex-col items-center justify-center gap-4 p-6 text-center'>
<h1 className='text-2xl font-semibold'>Not found</h1>
<p className='text-muted-foreground max-w-md text-sm'>
This item does not exist, or you do not have access to it.
</p>
<Button asChild>
<Link href='/dashboard'>Back to dashboard</Link>
</Button>
</main>
);
export default NotFound;
@@ -1,5 +1,6 @@
'use client'; 'use client';
import type { FunctionReturnType } from 'convex/server';
import { useEffect } from 'react'; import { useEffect } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
@@ -15,13 +16,33 @@ const AgentWorkspacePage = () => {
const router = useRouter(); const router = useRouter();
const params = useParams<{ spoonId: string; jobId: string }>(); const params = useParams<{ spoonId: string; jobId: string }>();
const jobId = params.jobId as Id<'agentJobs'>; const jobId = params.jobId as Id<'agentJobs'>;
const job = useQuery(api.agentJobs.get, { jobId }); const job = useQuery(api.agentJobs.get, { jobId }) as
| FunctionReturnType<typeof api.agentJobs.get>
| null
| undefined;
useEffect(() => { useEffect(() => {
if (job?.threadId) router.replace(`/threads/${job.threadId}`); if (job?.threadId) router.replace(`/threads/${job.threadId}`);
}, [job?.threadId, router]); }, [job?.threadId, router]);
if (job?.threadId) { if (job === undefined) {
return (
<main className='text-muted-foreground p-6'>Loading workspace...</main>
);
}
if (job === null) {
return (
<main className='space-y-4 p-6'>
<p className='text-muted-foreground'>This workspace was not found.</p>
<Button asChild variant='outline' size='sm'>
<Link href={`/spoons/${params.spoonId}`}>Back to Spoon</Link>
</Button>
</main>
);
}
if (job.threadId) {
return ( return (
<main className='text-muted-foreground p-6'> <main className='text-muted-foreground p-6'>
Opening thread workspace... Opening thread workspace...
@@ -1,5 +1,6 @@
'use client'; 'use client';
import type { FunctionReturnType } from 'convex/server';
import { useState } from 'react'; import { useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
@@ -33,7 +34,10 @@ const ThreadDetailPage = () => {
const router = useRouter(); const router = useRouter();
const params = useParams<{ threadId: string }>(); const params = useParams<{ threadId: string }>();
const threadId = params.threadId as Id<'threads'>; const threadId = params.threadId as Id<'threads'>;
const details = useQuery(api.threads.get, { threadId }); const details = useQuery(api.threads.get, { threadId }) as
| FunctionReturnType<typeof api.threads.get>
| null
| undefined;
const createJob = useMutation(api.agentJobs.createForThread); const createJob = useMutation(api.agentJobs.createForThread);
const markResolved = useMutation(api.threads.markResolved); const markResolved = useMutation(api.threads.markResolved);
const cancel = useMutation(api.threads.cancel); const cancel = useMutation(api.threads.cancel);
@@ -43,6 +47,14 @@ const ThreadDetailPage = () => {
return <main className='text-muted-foreground p-6'>Loading thread...</main>; return <main className='text-muted-foreground p-6'>Loading thread...</main>;
} }
if (details === null) {
return (
<main className='text-muted-foreground p-6'>
This thread was not found.
</main>
);
}
const { thread, spoon, latestJob } = details; const { thread, spoon, latestJob } = details;
if (latestJob && spoon) { if (latestJob && spoon) {
return ( return (
+1 -1
View File
@@ -710,7 +710,7 @@ export const get = query({
handler: async (ctx, { jobId }) => { handler: async (ctx, { jobId }) => {
const ownerId = await getRequiredUserId(ctx); const ownerId = await getRequiredUserId(ctx);
const job = await ctx.db.get(jobId); const job = await ctx.db.get(jobId);
if (job?.ownerId !== ownerId) throw new ConvexError('Agent job not found.'); if (job?.ownerId !== ownerId) return null;
return job; return job;
}, },
}); });
+1 -1
View File
@@ -211,7 +211,7 @@ export const get = query({
handler: async (ctx, { threadId }) => { handler: async (ctx, { threadId }) => {
const ownerId = await getRequiredUserId(ctx); const ownerId = await getRequiredUserId(ctx);
const thread = await ctx.db.get(threadId); const thread = await ctx.db.get(threadId);
if (thread?.ownerId !== ownerId) throw new ConvexError('Thread not found.'); if (thread?.ownerId !== ownerId) return null;
const spoon = thread.spoonId ? await ctx.db.get(thread.spoonId) : null; const spoon = thread.spoonId ? await ctx.db.get(thread.spoonId) : null;
const job = thread.latestAgentJobId const job = thread.latestAgentJobId
? await ctx.db.get(thread.latestAgentJobId) ? await ctx.db.get(thread.latestAgentJobId)
@@ -0,0 +1,118 @@
import { convexTest } from 'convex-test';
import { describe, expect, test } from 'vitest';
import type { Id } from '../../convex/_generated/dataModel.js';
import { api } from '../../convex/_generated/api.js';
import schema from '../../convex/schema';
const modules = import.meta.glob('../../convex/**/*.*s');
const createUser = async (t: ReturnType<typeof convexTest>, email: string) =>
await t.mutation(async (ctx) => {
return await ctx.db.insert('users', { email, name: email });
});
const authed = (t: ReturnType<typeof convexTest>, userId: string) =>
t.withIdentity({
subject: `${userId}|session`,
issuer: 'https://convex.test',
});
const seedDetails = async (
t: ReturnType<typeof convexTest>,
ownerId: Id<'users'>,
) =>
await t.mutation(async (ctx) => {
const now = Date.now();
const spoonId = await ctx.db.insert('spoons', {
ownerId,
name: 'Detail Spoon',
provider: 'gitea',
upstreamOwner: 'upstream',
upstreamRepo: 'detail',
upstreamDefaultBranch: 'main',
upstreamUrl: 'https://git.example.com/upstream/detail',
forkOwner: 'team',
forkRepo: 'detail-spoon',
forkUrl: 'https://git.example.com/team/detail-spoon',
visibility: 'private',
maintenanceMode: 'watch',
syncCadence: 'daily',
productionRefStrategy: 'default_branch',
status: 'active',
createdAt: now,
updatedAt: now,
});
const threadId = await ctx.db.insert('threads', {
ownerId,
spoonId,
title: 'Private thread',
source: 'user_request',
status: 'open',
priority: 'normal',
createdAt: now,
updatedAt: now,
});
const requestId = await ctx.db.insert('agentRequests', {
spoonId,
ownerId,
prompt: 'Private work',
status: 'running',
createdAt: now,
updatedAt: now,
});
const jobId = await ctx.db.insert('agentJobs', {
spoonId,
ownerId,
agentRequestId: requestId,
threadId,
status: 'running',
prompt: 'Private work',
runtime: 'opencode',
baseBranch: 'main',
workBranch: 'spoon/private',
forkOwner: 'team',
forkRepo: 'detail-spoon',
forkUrl: 'https://git.example.com/team/detail-spoon',
upstreamOwner: 'upstream',
upstreamRepo: 'detail',
selectedSecretIds: [],
model: 'openai/gpt-5.1-codex',
reasoningEffort: 'medium',
createdAt: now,
updatedAt: now,
});
await ctx.db.patch(threadId, { latestAgentJobId: jobId });
return { threadId, jobId };
});
describe('detail queries for unavailable records', () => {
test('return null for records owned by another user', async () => {
const t = convexTest(schema, modules);
const ownerId = (await createUser(t, 'owner@example.com')) as Id<'users'>;
const otherId = await createUser(t, 'other@example.com');
const { threadId, jobId } = await seedDetails(t, ownerId);
const other = authed(t, otherId);
await expect(
other.query(api.threads.get, { threadId }),
).resolves.toBeNull();
await expect(other.query(api.agentJobs.get, { jobId })).resolves.toBeNull();
});
test('return null for records that do not exist', async () => {
const t = convexTest(schema, modules);
const ownerId = (await createUser(t, 'owner@example.com')) as Id<'users'>;
const owner = authed(t, ownerId);
const { threadId, jobId } = await seedDetails(t, ownerId);
await t.mutation(async (ctx) => {
await ctx.db.delete(jobId);
await ctx.db.delete(threadId);
});
await expect(
owner.query(api.threads.get, { threadId }),
).resolves.toBeNull();
await expect(owner.query(api.agentJobs.get, { jobId })).resolves.toBeNull();
});
});