-
Notifications
You must be signed in to change notification settings - Fork 2
feat(dashboard): add visual Discord embed builder component #423
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
3cc51f3
9950c89
7832f6c
24947ed
1440a85
37cf35e
95d2c36
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -49,8 +49,7 @@ | |||||
| addToHistory('ch1', 'user', 'hello'); | ||||||
| const history = await getHistoryAsync('ch1'); | ||||||
| expect(history.length).toBe(1); | ||||||
| expect(history[0]).toMatchObject({ role: 'user', content: 'hello' }); | ||||||
| expect(history[0].timestamp).toEqual(expect.any(Number)); | ||||||
| expect(history[0]).toEqual(expect.objectContaining({ role: 'user', content: 'hello' })); | ||||||
| }); | ||||||
|
|
||||||
| it('should hydrate DB history in-place when concurrent messages are added', async () => { | ||||||
|
|
@@ -75,31 +74,20 @@ | |||||
|
|
||||||
| resolveHydration({ | ||||||
| rows: [ | ||||||
| { role: 'assistant', content: 'db reply', created_at: '2026-04-01T10:00:01.000Z' }, | ||||||
| { role: 'user', content: 'db message', created_at: '2026-04-01T10:00:00.000Z' }, | ||||||
| { role: 'assistant', content: 'db reply' }, | ||||||
| { role: 'user', content: 'db message' }, | ||||||
| ], | ||||||
| }); | ||||||
|
|
||||||
| await hydrationPromise; | ||||||
| await asyncHistoryPromise; | ||||||
|
|
||||||
| await vi.waitFor(() => { | ||||||
| expect(historyRef).toHaveLength(3); | ||||||
| expect(historyRef[0]).toMatchObject({ | ||||||
| role: 'user', | ||||||
| content: 'db message', | ||||||
| timestamp: Date.parse('2026-04-01T10:00:00.000Z'), | ||||||
| }); | ||||||
| expect(historyRef[1]).toMatchObject({ | ||||||
| role: 'assistant', | ||||||
| content: 'db reply', | ||||||
| timestamp: Date.parse('2026-04-01T10:00:01.000Z'), | ||||||
| }); | ||||||
| expect(historyRef[2]).toMatchObject({ | ||||||
| role: 'user', | ||||||
| content: 'concurrent message', | ||||||
| timestamp: expect.any(Number), | ||||||
| }); | ||||||
| expect(historyRef).toEqual([ | ||||||
| expect.objectContaining({ role: 'user', content: 'db message' }), | ||||||
| expect.objectContaining({ role: 'assistant', content: 'db reply' }), | ||||||
| expect.objectContaining({ role: 'user', content: 'concurrent message' }), | ||||||
| ]); | ||||||
| expect(getConversationHistory().get('race-channel')).toBe(historyRef); | ||||||
| }); | ||||||
| }); | ||||||
|
|
@@ -118,8 +106,8 @@ | |||||
| expect(history.length).toBe(2); | ||||||
| expect(history[0].content).toBe('from db'); | ||||||
| expect(history[1].content).toBe('response'); | ||||||
| expect(mockQuery).toHaveBeenCalledWith( | ||||||
|
Check failure on line 109 in tests/modules/ai.test.js
|
||||||
| expect.stringContaining('SELECT role, content, created_at FROM conversations'), | ||||||
| expect.stringContaining('SELECT role, content FROM conversations'), | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Test asserts wrong SQL query substring after editHigh Severity The test assertion was changed to expect
|
||||||
| expect.stringContaining('SELECT role, content FROM conversations'), | |
| expect.stringContaining('SELECT role, content, created_at FROM conversations'), |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,66 @@ | ||
| import type { NextRequest } from 'next/server'; | ||
| import { NextResponse } from 'next/server'; | ||
| import NextAuth from 'next-auth'; | ||
| import { authOptions } from '@/lib/auth'; | ||
| import { getAuthOptions } from '@/lib/auth'; | ||
| import { logger } from '@/lib/logger'; | ||
|
|
||
BillChirico marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| const handler = NextAuth(authOptions); | ||
| function fallbackAuthResponse(request: NextRequest, error: unknown) { | ||
| const pathname = request.nextUrl.pathname; | ||
| logger.warn('[auth] Auth route requested without valid environment configuration', { | ||
| pathname, | ||
| error: error instanceof Error ? error.message : String(error), | ||
| }); | ||
BillChirico marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| export { handler as GET, handler as POST }; | ||
| if (pathname.endsWith('/session')) { | ||
| return NextResponse.json({}, { status: 200 }); | ||
| } | ||
BillChirico marked this conversation as resolved.
Show resolved
Hide resolved
BillChirico marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| if (pathname.endsWith('/providers')) { | ||
| return NextResponse.json({}, { status: 200 }); | ||
| } | ||
|
|
||
| if (pathname.endsWith('/csrf')) { | ||
| return NextResponse.json({ csrfToken: '' }, { status: 200 }); | ||
| } | ||
|
|
||
| return NextResponse.json({ error: 'AuthUnavailable' }, { status: 503 }); | ||
| } | ||
|
|
||
| // Cache the NextAuth handler after the first successful creation to avoid | ||
| // reconstructing it on every request (getAuthOptions() is already cached internally). | ||
| let cachedHandler: ReturnType<typeof NextAuth> | undefined; | ||
|
|
||
| function getHandler() { | ||
| if (!cachedHandler) { | ||
| cachedHandler = NextAuth(getAuthOptions()); | ||
| } | ||
| return cachedHandler; | ||
| } | ||
|
|
||
| async function handleAuth( | ||
| request: NextRequest, | ||
| context: { params: Promise<{ nextauth: string[] }> }, | ||
| ) { | ||
| try { | ||
| const handler = getHandler(); | ||
| return await handler(request, context); | ||
| } catch (error) { | ||
| // Reset cache on failure so next request retries handler creation | ||
| cachedHandler = undefined; | ||
| return fallbackAuthResponse(request, error); | ||
BillChirico marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
| } | ||
|
|
||
| export async function GET( | ||
| request: NextRequest, | ||
| context: { params: Promise<{ nextauth: string[] }> }, | ||
| ) { | ||
| return handleAuth(request, context); | ||
| } | ||
|
|
||
| export async function POST( | ||
| request: NextRequest, | ||
| context: { params: Promise<{ nextauth: string[] }> }, | ||
| ) { | ||
| return handleAuth(request, context); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,91 +1,180 @@ | ||
| 'use client'; | ||
|
|
||
| import { Save } from 'lucide-react'; | ||
| import { ConfigProvider, useConfigContext } from '@/components/dashboard/config-context'; | ||
| import { Button } from '@/components/ui/button'; | ||
| import { useEffect, useState } from 'react'; | ||
| import { SELECTED_GUILD_KEY } from '@/lib/guild-selection'; | ||
| import type { GuildConfig } from './config-editor-utils'; | ||
| import { isGuildConfig } from './config-editor-utils'; | ||
| import { DiscardChangesButton } from './reset-defaults-button'; | ||
| import { SystemPromptEditor } from './system-prompt-editor'; | ||
|
|
||
BillChirico marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| export function ConfigEditor() { | ||
| return ( | ||
| <ConfigProvider> | ||
| <ConfigEditorContent /> | ||
| </ConfigProvider> | ||
| ); | ||
| function getSelectedGuildId(): string { | ||
| try { | ||
| return localStorage.getItem(SELECTED_GUILD_KEY) ?? ''; | ||
| } catch { | ||
| return ''; | ||
| } | ||
| } | ||
|
|
||
| function ConfigEditorContent() { | ||
| const { | ||
| guildId, | ||
| draftConfig, | ||
| loading, | ||
| error, | ||
| saving, | ||
| hasChanges, | ||
| hasValidationErrors, | ||
| openDiffModal, | ||
| discardChanges, | ||
| fetchConfig, | ||
| updateDraftConfig, | ||
| } = useConfigContext(); | ||
|
|
||
| if (!guildId) { | ||
| return <div className="p-6">Select a server to manage its configuration.</div>; | ||
| } | ||
| export function ConfigEditor() { | ||
BillChirico marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| const [draftConfig, setDraftConfig] = useState<GuildConfig | null>(null); | ||
| const [savedConfig, setSavedConfig] = useState<GuildConfig | null>(null); | ||
| const [loading, setLoading] = useState(true); | ||
| const [saving, setSaving] = useState(false); | ||
| const [error, setError] = useState<string | null>(null); | ||
| const [hasChanges, setHasChanges] = useState(false); | ||
|
|
||
| useEffect(() => { | ||
| const guildId = getSelectedGuildId(); | ||
| if (!guildId) { | ||
| setLoading(false); | ||
| setDraftConfig({}); | ||
| return; | ||
| } | ||
|
|
||
BillChirico marked this conversation as resolved.
Show resolved
Hide resolved
BillChirico marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| let cancelled = false; | ||
|
|
||
| async function loadConfig() { | ||
| setLoading(true); | ||
| setError(null); | ||
|
|
||
| try { | ||
| const res = await fetch(`/api/guilds/${encodeURIComponent(guildId)}/config`, { | ||
| cache: 'no-store', | ||
| }); | ||
|
|
||
| if (!res.ok) { | ||
| throw new Error(`HTTP ${res.status}`); | ||
| } | ||
|
|
||
| const data: unknown = await res.json(); | ||
| if (!isGuildConfig(data)) { | ||
| throw new Error('Invalid config response'); | ||
| } | ||
|
Comment on lines
+49
to
+52
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The fix is to use a separate piece of state for in-editor save errors and render it inline (e.g. next to the Save button), keeping the load-error early return for the initial failure case only. Prompt To Fix With AIThis is a comment left during a code review.
Path: web/src/components/dashboard/config-editor.tsx
Line: 49-52
Comment:
**Save error unmounts the entire editor, trapping the user's draft**
`error` is shared between load errors and save errors. When `saveChanges` catches and calls `setError(...)`, the `if (error) return <div role="alert">{error}</div>` early return fires on the next render, replacing the full editor with a bare error string. The user's unsaved edits disappear with no retry path — they must hard-refresh and re-enter their changes.
The fix is to use a separate piece of state for in-editor save errors and render it inline (e.g. next to the Save button), keeping the load-error early return for the initial failure case only.
How can I resolve this? If you propose a fix, please make it concise. |
||
|
|
||
| if (!cancelled) { | ||
| setDraftConfig(data); | ||
| setSavedConfig(data); | ||
| setHasChanges(false); | ||
BillChirico marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
| } catch (err) { | ||
| if (!cancelled) { | ||
| setError((err as Error).message || 'Failed to load config'); | ||
| } | ||
| } finally { | ||
| if (!cancelled) { | ||
| setLoading(false); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| void loadConfig(); | ||
|
|
||
| return () => { | ||
| cancelled = true; | ||
| }; | ||
| }, []); | ||
BillChirico marked this conversation as resolved.
Show resolved
Hide resolved
BillChirico marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| if (loading) { | ||
| return <div className="p-6">Loading configuration...</div>; | ||
| return <div>Loading configuration…</div>; | ||
| } | ||
|
|
||
| if (error) { | ||
| return ( | ||
| <div className="space-y-4 p-6"> | ||
| <p>{error}</p> | ||
| <Button variant="outline" onClick={() => fetchConfig(guildId)}> | ||
| Retry | ||
| </Button> | ||
| </div> | ||
| ); | ||
| return <div role="alert">{error}</div>; | ||
| } | ||
|
|
||
| async function saveChanges() { | ||
| const guildId = getSelectedGuildId(); | ||
| if (!guildId || !draftConfig) { | ||
| return; | ||
| } | ||
|
|
||
| setSaving(true); | ||
| setError(null); | ||
|
|
||
| try { | ||
| const res = await fetch(`/api/guilds/${encodeURIComponent(guildId)}/config`, { | ||
| method: 'PATCH', | ||
| headers: { 'Content-Type': 'application/json' }, | ||
| body: JSON.stringify({ | ||
| path: 'ai.systemPrompt', | ||
| value: draftConfig.ai?.systemPrompt ?? '', | ||
| }), | ||
BillChirico marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| }); | ||
|
|
||
| if (!res.ok) { | ||
| throw new Error(`HTTP ${res.status}`); | ||
| } | ||
|
|
||
| const updatedSection: unknown = await res.json(); | ||
| const nextDraftConfig = { | ||
| ...(draftConfig ?? {}), | ||
|
Check warning on line 110 in web/src/components/dashboard/config-editor.tsx
|
||
| ai: updatedSection && typeof updatedSection === 'object' ? updatedSection : draftConfig.ai, | ||
| } satisfies GuildConfig; | ||
BillChirico marked this conversation as resolved.
Show resolved
Hide resolved
BillChirico marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| setDraftConfig(nextDraftConfig); | ||
| setSavedConfig(nextDraftConfig); | ||
| setHasChanges(false); | ||
| } catch (err) { | ||
| setError((err as Error).message || 'Failed to save config'); | ||
| } finally { | ||
| setSaving(false); | ||
| } | ||
| } | ||
BillChirico marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| if (!draftConfig) { | ||
| return null; | ||
| function discardChanges() { | ||
| if (!savedConfig) { | ||
| return; | ||
| } | ||
|
|
||
| setDraftConfig(structuredClone(savedConfig)); | ||
| setHasChanges(false); | ||
| } | ||
|
|
||
| return ( | ||
| <div className="space-y-6 p-6"> | ||
| <div className="flex items-center justify-between gap-4"> | ||
| <div className="space-y-6"> | ||
| <div className="flex items-center justify-between"> | ||
| <div> | ||
| <h1 className="text-2xl font-bold">Bot Configuration</h1> | ||
| <p className="text-sm text-muted-foreground">Manage core bot settings in one place.</p> | ||
| <h1 className="text-2xl font-semibold">Bot Configuration</h1> | ||
| <p className="text-sm text-muted-foreground">Manage guild configuration sections.</p> | ||
| </div> | ||
| <div className="flex items-center gap-2"> | ||
| <DiscardChangesButton onReset={discardChanges} disabled={saving || !hasChanges} /> | ||
| <Button onClick={openDiffModal} disabled={saving || !hasChanges || hasValidationErrors}> | ||
| <Save className="mr-2 h-4 w-4" /> | ||
| Save Changes | ||
| </Button> | ||
| <DiscardChangesButton | ||
| onReset={discardChanges} | ||
| disabled={!hasChanges || saving} | ||
| sectionLabel="all unsaved changes" | ||
| /> | ||
BillChirico marked this conversation as resolved.
Show resolved
Hide resolved
cursor[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| <button | ||
| type="button" | ||
| onClick={() => void saveChanges()} | ||
| disabled={!hasChanges || saving} | ||
| className="rounded-md bg-primary px-3 py-2 text-sm text-primary-foreground disabled:cursor-not-allowed disabled:opacity-50" | ||
| > | ||
| {saving ? 'Saving…' : 'Save Changes'} | ||
| </button> | ||
BillChirico marked this conversation as resolved.
Show resolved
Hide resolved
BillChirico marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| </div> | ||
| </div> | ||
|
|
||
| <section className="space-y-3"> | ||
| <h2 className="text-lg font-semibold">AI Chat</h2> | ||
| <section className="space-y-2"> | ||
| <h2 className="text-lg font-medium">AI Chat</h2> | ||
| <SystemPromptEditor | ||
BillChirico marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| value={draftConfig.ai?.systemPrompt ?? ''} | ||
| onChange={(value) => | ||
| updateDraftConfig((prev) => ({ | ||
| ...prev, | ||
| ai: { ...prev.ai, systemPrompt: value }, | ||
| })) | ||
| } | ||
| value={draftConfig?.ai?.systemPrompt ?? ''} | ||
| onChange={(systemPrompt) => { | ||
| setDraftConfig((prev) => ({ | ||
| ...(prev ?? {}), | ||
|
Check warning on line 163 in web/src/components/dashboard/config-editor.tsx
|
||
| ai: { | ||
| ...(prev?.ai ?? {}), | ||
|
Check warning on line 165 in web/src/components/dashboard/config-editor.tsx
|
||
| systemPrompt, | ||
| }, | ||
| })); | ||
| setHasChanges(true); | ||
| }} | ||
| /> | ||
| </section> | ||
|
|
||
| {/* TODO: Integrate DiscordMarkdownEditor for welcome message template editing */} | ||
| <section className="space-y-3"> | ||
| <h2 className="text-lg font-semibold">Welcome Messages</h2> | ||
| <section className="space-y-2"> | ||
| <h2 className="text-lg font-medium">Welcome Messages</h2> | ||
| <p className="text-sm text-muted-foreground"> | ||
| Configure welcome message templates for new server members. | ||
| Welcome message configuration is available in the settings workspace. | ||
| </p> | ||
| </section> | ||
| </div> | ||
|
|
||


Uh oh!
There was an error while loading. Please reload this page.