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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
name: Quality checks

on:
pull_request:
branches: [main]
push:
branches: [main]

permissions:
contents: read

jobs:
quality:
name: Lint, type check, tests, and build
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Check out repository
uses: actions/checkout@v4

- name: Set up pnpm
uses: pnpm/action-setup@v4

- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Lint
run: pnpm lint

- name: Type check
run: pnpm check

- name: Run deterministic tests
env:
DEV_DESK_CHAT_HISTORY_METADATA_ENABLED: 'true'
run: pnpm exec tsx --test $(find server app -name '*.test.ts' ! -name 'groq.secret.test.ts' -print)

- name: Build production app
run: pnpm build
7 changes: 4 additions & 3 deletions app/components/AuthControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,22 +19,23 @@
if (!configured) return;
const supabase = createSupabaseBrowserClient();
void supabase.auth.getUser().then(({ data }) => { setSessionEmail(data.user?.email ?? undefined); setSessionUserId(data.user?.id); setAuthResolved(true); });
const { data: listener } = supabase.auth.onAuthStateChange((_event, nextSession) => { setSessionEmail(nextSession?.user?.email ?? undefined); setSessionUserId(nextSession?.user?.id); setAuthResolved(true); });
const { data: listener } = supabase.auth.onAuthStateChange((_event, nextSession) => { setSessionEmail(nextSession?.user?.email ?? undefined); setSessionUserId(nextSession?.user?.id); setAuthResolved(true); if (!nextSession) { setGithubState('disconnected'); setGithubLogin(''); setRepos([]); setReposOpen(false); } });
return () => listener.subscription.unsubscribe();
}, [configured]);

useEffect(() => { if (authResolved) onAuthUserChange?.(sessionUserId); }, [authResolved, onAuthUserChange, sessionUserId]);

async function requestRepos(openPanel: boolean) {
setGithubState('checking');
const response = await fetch('/api/github/repos'); const payload = await response.json();
if (!response.ok) throw new Error(payload.error ?? 'Could not load repositories.');
setRepos(payload.repositories ?? []); setGithubState(payload.connected ? 'connected' : 'disconnected'); setGithubLogin(payload.login ?? '');
if (openPanel) setReposOpen(true);
}

useEffect(() => {
if (!sessionEmail) { setGithubState('disconnected'); setGithubLogin(''); setRepos([]); return; }
let active = true; setGithubState('checking'); void requestRepos(false).catch(() => { if (active) setGithubState('disconnected'); });
if (!sessionEmail) return;
let active = true; window.queueMicrotask(() => { void requestRepos(false).catch(() => { if (active) setGithubState('disconnected'); }); });
return () => { active = false; };
}, [sessionEmail]);

Expand All @@ -48,7 +49,7 @@

async function logout(openSignIn = false) { onLogout?.(); const supabase = createSupabaseBrowserClient(); await supabase.auth.signOut(); setSessionEmail(undefined); setSessionUserId(undefined); setRepos([]); setGithubLogin(''); setReposOpen(false); setMenuOpen(false); setSettingsOpen(false); setOpen(openSignIn); }
async function changePassword() { if (newPassword.length < 8) return setError('Password must contain at least 8 characters.'); if (newPassword !== confirmPassword) return setError('Passwords do not match.'); setLoading(true); setError(''); setNotice(''); const { error: updateError } = await createSupabaseBrowserClient().auth.updateUser({ password: newPassword }); if (updateError) setError(updateError.message); else { setNotice('Password changed successfully.'); setNewPassword(''); setConfirmPassword(''); } setLoading(false); }
function connectGithub() { window.location.href = '/api/github/start'; }

Check warning on line 52 in app/components/AuthControls.tsx

View workflow job for this annotation

GitHub Actions / Lint, type check, tests, and build

Do not use `window.location.href` to navigate to internal Next.js pages. Use `redirect()` in the render phase, or `useRouter().push()` in Client Components' event handlers instead. See: https://nextjs.org/docs/messages/no-location-assign-relative-destination
async function loadRepos() { setLoading(true); setError(''); try { await requestRepos(true); } catch (issue) { setError(issue instanceof Error ? issue.message : 'Could not load repositories.'); setGithubState('disconnected'); } finally { setLoading(false); } }
function selectRepo(repo: GitHubRepo) { onRepositorySelected?.(repo); setReposOpen(false); }
function openHistory() { setMenuOpen(false); onOpenHistory?.(); }
Expand Down
15 changes: 8 additions & 7 deletions app/components/ComposerMediaPortal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import './ComposerMediaPortal.css';

export function ComposerMediaPortal() {
const [target, setTarget] = useState<HTMLElement | null>(null);
const [target] = useState<HTMLElement | null>(() => typeof document === 'undefined' ? null : document.createElement('div'));
const [open, setOpen] = useState(false);
const [imagePanel, setImagePanel] = useState(false);
const [imagePrompt, setImagePrompt] = useState('');
Expand All @@ -18,15 +18,16 @@
const [imageStatus, setImageStatus] = useState('');

useEffect(() => {
if (!target) return;
const tools = document.querySelector<HTMLElement>('.composer-tools');
const send = tools?.querySelector<HTMLElement>('.send-button');
if (!tools || !send) return;
const host = document.createElement('div');
host.className = 'composer-media-portal-host';
tools.insertBefore(host, send);
setTarget(host);
return () => { host.remove(); setTarget(null); };
}, []);
// This DOM node is intentionally prepared for an external portal host.
// eslint-disable-next-line react-hooks/immutability
target.className = 'composer-media-portal-host';
tools.insertBefore(target, send);
return () => { target.remove(); };
}, [target]);


async function generateImage(event: React.FormEvent) {
Expand All @@ -48,5 +49,5 @@
}

if (!target) return null;
return createPortal(<div className="composer-media-menu"><button className={open ? 'composer-plus-button active' : 'composer-plus-button'} onClick={() => setOpen((value) => !value)} aria-label="Open media tools" aria-expanded={open}>{open ? <X size={16} /> : <Plus size={17} />}</button>{open && <div className="composer-media-popover"><div className="composer-media-head"><span><Sparkles size={13} /> CREATE & VOICE</span><button onClick={() => setOpen(false)} aria-label="Close media tools"><X size={13} /></button></div><button className="media-action" onClick={() => { window.location.href = '/voice-call'; }}><PhoneCall size={16} /><span><b>Voice call</b><small>Open full-screen AI voice conversation</small></span></button><button className="media-action" onClick={() => { setImagePanel((value) => !value); setImageError(''); }}><ImageIcon size={16} /><span><b>Image generation</b><small>Create an image from a prompt</small></span></button>{imagePanel && <form className="image-generation-form" onSubmit={(event) => void generateImage(event)}><textarea value={imagePrompt} onChange={(event) => setImagePrompt(event.target.value)} placeholder="Describe the image you want to create…" rows={3} maxLength={1000} /><div><select value={aspectRatio} onChange={(event) => setAspectRatio(event.target.value)} aria-label="Image aspect ratio"><option value="1:1">Square</option><option value="4:3">Landscape</option><option value="3:4">Portrait</option><option value="16:9">Wide</option><option value="9:16">Story</option></select><button disabled={imageLoading}>{imageLoading ? <><LoaderCircle size={13} className="spin" /> Creating…</> : <><ImageIcon size={13} /> Generate</>}</button></div>{(imageLoading || imageStatus) && <div className={imageError ? 'image-progress is-error' : imageDataUrl ? 'image-progress is-ready' : 'image-progress'} aria-live="polite"><div><span>{imageLoading && <LoaderCircle size={12} className="spin" />}{imageStatus || 'Preparing image generation…'}</span><b>{imageProgress}%</b></div><div className="image-progress-track" role="progressbar" aria-label="Image generation progress" aria-valuemin={0} aria-valuemax={100} aria-valuenow={imageProgress}><i style={{ width: `${imageProgress}%` }} /></div></div>}{imageError && <p className="media-error">{imageError}</p>}{imageDataUrl && <figure className="generated-image-result"><img src={imageDataUrl} alt={imagePrompt || 'AI generated image'} onLoad={() => { if (imageStatus.includes('loading through')) setImageStatus('Image ready — loaded through the backup free delivery path.'); }} onError={() => { setImageDataUrl(''); setImageProgress(0); setImageStatus('Backup image delivery could not load — please retry.'); setImageError('The backup free image delivery did not load. Please retry in a moment.'); }} /><figcaption><Volume2 size={12} /> Generated securely on the server <a href={imageDataUrl} download="devdesk-ai-image.png">Download</a></figcaption></figure>}</form>}</div>}</div>, target);

Check warning on line 52 in app/components/ComposerMediaPortal.tsx

View workflow job for this annotation

GitHub Actions / Lint, type check, tests, and build

Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element

Check warning on line 52 in app/components/ComposerMediaPortal.tsx

View workflow job for this annotation

GitHub Actions / Lint, type check, tests, and build

Do not use `window.location.href` to navigate to internal Next.js pages. Use `redirect()` in the render phase, or `useRouter().push()` in Client Components' event handlers instead. See: https://nextjs.org/docs/messages/no-location-assign-relative-destination
}
3 changes: 1 addition & 2 deletions app/components/ProjectHub.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client';

import { ChangeEvent, useEffect, useState } from 'react';
import { Archive, CheckCircle2, FileCode2, FolderGit2, GitBranch, LoaderCircle, Save, UploadCloud, X } from 'lucide-react';

Check warning on line 4 in app/components/ProjectHub.tsx

View workflow job for this annotation

GitHub Actions / Lint, type check, tests, and build

'FileCode2' is defined but never used
import type { GitHubRepo } from './AuthControls';

export type ImportedProjectRecord = { id: string; name: string; branch: string | null; status: string; source: { fileCount: number; treeTruncated?: boolean; skippedCount: number }; files: Array<{ path: string; language: string; byteSize: number }> };
Expand All @@ -10,11 +10,10 @@
type Tab = 'create' | 'github' | 'zip' | 'manage' | 'commit';

export function ProjectHub({ open, initialTab = 'create', onClose, selectedRepo, importedProject, onImported, onRemoved }: Props) {
const [tab, setTab] = useState<Tab>('create'); const [repos, setRepos] = useState<GitHubRepo[]>([]); const [projectName, setProjectName] = useState(''); const [archive, setArchive] = useState<File>();
const [tab, setTab] = useState<Tab>(initialTab); const [repos, setRepos] = useState<GitHubRepo[]>([]); const [projectName, setProjectName] = useState(''); const [archive, setArchive] = useState<File>();
const [loading, setLoading] = useState(false); const [notice, setNotice] = useState(''); const [error, setError] = useState(''); const [filePath, setFilePath] = useState(''); const [fileContent, setFileContent] = useState(''); const [fileSha, setFileSha] = useState(''); const [commitMessage, setCommitMessage] = useState(''); const [approved, setApproved] = useState(false); const [createNewFile, setCreateNewFile] = useState(false); const [projects, setProjects] = useState<ProjectSummary[]>([]); const [confirmRemovalId, setConfirmRemovalId] = useState<string>();

useEffect(() => { if (!open) return; setTab(initialTab); setError(''); setNotice(''); }, [open, initialTab]);
useEffect(() => { if (!open) return; if (tab === 'github') void loadRepos(); if (tab === 'manage') void loadProjects(); }, [open, tab]);

Check warning on line 16 in app/components/ProjectHub.tsx

View workflow job for this annotation

GitHub Actions / Lint, type check, tests, and build

React Hook useEffect has missing dependencies: 'loadProjects' and 'loadRepos'. Either include them or remove the dependency array
async function apiJson(url: string, init?: RequestInit) { const response = await fetch(url, init); const payload = await response.json(); if (!response.ok) throw new Error(payload.error ?? 'Request failed.'); return payload; }
async function loadRepos() { try { const payload = await apiJson('/api/github/repos'); setRepos(payload.repositories ?? []); } catch (issue) { setError(issue instanceof Error ? issue.message : 'Could not load GitHub repositories.'); } }
async function loadProjects() { setLoading(true); try { const payload = await apiJson('/api/projects'); setProjects(payload.projects ?? []); } catch (issue) { setError(issue instanceof Error ? issue.message : 'Could not load projects.'); } finally { setLoading(false); } }
Expand Down
Loading
Loading