diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b47d40a --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/app/components/AuthControls.tsx b/app/components/AuthControls.tsx index ef41d7e..abcf0fd 100644 --- a/app/components/AuthControls.tsx +++ b/app/components/AuthControls.tsx @@ -19,13 +19,14 @@ export function AuthControls({ onRepositorySelected, onOpenHistory, onOpenProjec 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 ?? ''); @@ -33,8 +34,8 @@ export function AuthControls({ onRepositorySelected, onOpenHistory, onOpenProjec } 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]); diff --git a/app/components/ComposerMediaPortal.tsx b/app/components/ComposerMediaPortal.tsx index 11654fc..e95fa83 100644 --- a/app/components/ComposerMediaPortal.tsx +++ b/app/components/ComposerMediaPortal.tsx @@ -6,7 +6,7 @@ import { createPortal } from 'react-dom'; import './ComposerMediaPortal.css'; export function ComposerMediaPortal() { - const [target, setTarget] = useState(null); + const [target] = useState(() => typeof document === 'undefined' ? null : document.createElement('div')); const [open, setOpen] = useState(false); const [imagePanel, setImagePanel] = useState(false); const [imagePrompt, setImagePrompt] = useState(''); @@ -18,15 +18,16 @@ export function ComposerMediaPortal() { const [imageStatus, setImageStatus] = useState(''); useEffect(() => { + if (!target) return; const tools = document.querySelector('.composer-tools'); const send = tools?.querySelector('.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) { diff --git a/app/components/ProjectHub.tsx b/app/components/ProjectHub.tsx index 10dd79a..5d0b1fc 100644 --- a/app/components/ProjectHub.tsx +++ b/app/components/ProjectHub.tsx @@ -10,10 +10,9 @@ type Props = { open: boolean; initialTab?: Tab; onClose: () => void; selectedRep type Tab = 'create' | 'github' | 'zip' | 'manage' | 'commit'; export function ProjectHub({ open, initialTab = 'create', onClose, selectedRepo, importedProject, onImported, onRemoved }: Props) { - const [tab, setTab] = useState('create'); const [repos, setRepos] = useState([]); const [projectName, setProjectName] = useState(''); const [archive, setArchive] = useState(); + const [tab, setTab] = useState(initialTab); const [repos, setRepos] = useState([]); const [projectName, setProjectName] = useState(''); const [archive, setArchive] = useState(); 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([]); const [confirmRemovalId, setConfirmRemovalId] = useState(); - 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]); 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.'); } } diff --git a/app/page.tsx b/app/page.tsx index 91c30d1..ac37afa 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -56,7 +56,7 @@ function BrandMark({ size = 'regular' }: { size?: 'regular' | 'large' }) { retur function LandingPage({ onAuthUserChange }: { onAuthUserChange: (userId?: string) => void }) { return
DevDeskAI code intelligence
-
SOURCE-BACKED DEVELOPER AI

Your codebase has context.
Give it a real workspace.

Import a repository or ZIP, ask precise engineering questions, and receive evidence-linked answers without exposing your keys in the browser.

Server-side AI boundary Private account workspace
+
SOURCE-BACKED DEVELOPER AI

Your codebase has context.
Give it a real workspace.

Import a repository or ZIP, ask precise engineering questions, and receive evidence-linked answers without exposing your keys in the browser.

Server-side AI boundary Private account workspace
REAL WORKFLOW

From code import to an answer you can trust.

01

Connect real code

Choose a GitHub repository or bounded ZIP import. Dependency folders, unsafe paths, and oversized files are filtered.

02

Ask the hard question

Discuss architecture, bugs, security risks, or a specific implementation while preserving the full conversation.

03

Review evidence

Source-backed analysis cites the imported files it used, and asks for clarification when scope does not match.

BUILT FOR REAL PROJECTS

Developer tooling without fake demos or browser-stored provider keys.

Safe source ingestion Authenticated workspaces Server-side model routing
DevDeskCreated by Abbas Hussain
Build with evidence, not guesses.
@@ -92,9 +92,10 @@ export default function Home() { const resetWorkspace = useCallback(() => { setChatMessages([]); setConversations([]); setSelectedRepo(undefined); setImportedProject(undefined); setAnalysis(undefined); setChatError(''); setActiveConversationId(undefined); setPrompt(''); setHistoryOpen(false); setShowUpload(false); setComposerRepoOpen(false); }, []); const clearAccountWorkspace = useCallback(() => { if (typeof window !== 'undefined') { purgeLegacyConversationRecords(); if (authUserId) [historyKey, selectedRepoKey, importedProjectKey].forEach((key) => window.localStorage.removeItem(accountStorageKey(key, authUserId)!)); } resetWorkspace(); }, [authUserId, resetWorkspace]); + const handleAuthUserChange = useCallback((nextUserId?: string) => { resetWorkspace(); setChatCooldownUntil(0); setAuthUserId(nextUserId); }, [resetWorkspace]); useEffect(() => { purgeLegacyConversationRecords(); }, []); - useEffect(() => { resetWorkspace(); setChatCooldownUntil(0); if (!authUserId) return; try { purgeLegacyConversationRecords(); const savedHistory = window.localStorage.getItem(accountStorageKey(historyKey, authUserId)!); const savedRepository = window.localStorage.getItem(accountStorageKey(selectedRepoKey, authUserId)!); const savedImport = window.localStorage.getItem(accountStorageKey(importedProjectKey, authUserId)!); const savedCooldown = Number(window.localStorage.getItem(accountStorageKey(chatCapacityCooldownKey, authUserId)!)); if (Number.isFinite(savedCooldown) && savedCooldown > Date.now()) setChatCooldownUntil(savedCooldown); if (savedHistory) setConversations((JSON.parse(savedHistory) as unknown[]).map(normalizeConversation).filter((item): item is Conversation => Boolean(item))); if (savedRepository) setSelectedRepo(JSON.parse(savedRepository) as GitHubRepo); if (savedImport) setImportedProject(JSON.parse(savedImport) as ImportedProject); } catch { setConversations([]); } }, [authUserId, resetWorkspace]); + useEffect(() => { if (!authUserId) return; window.queueMicrotask(() => { try { purgeLegacyConversationRecords(); const savedHistory = window.localStorage.getItem(accountStorageKey(historyKey, authUserId)!); const savedRepository = window.localStorage.getItem(accountStorageKey(selectedRepoKey, authUserId)!); const savedImport = window.localStorage.getItem(accountStorageKey(importedProjectKey, authUserId)!); const savedCooldown = Number(window.localStorage.getItem(accountStorageKey(chatCapacityCooldownKey, authUserId)!)); if (Number.isFinite(savedCooldown) && savedCooldown > Date.now()) setChatCooldownUntil(savedCooldown); if (savedHistory) setConversations((JSON.parse(savedHistory) as unknown[]).map(normalizeConversation).filter((item): item is Conversation => Boolean(item))); if (savedRepository) setSelectedRepo(JSON.parse(savedRepository) as GitHubRepo); if (savedImport) setImportedProject(JSON.parse(savedImport) as ImportedProject); } catch { setConversations([]); } }); }, [authUserId]); function openHub(tab: HubTab = 'create') { setHubTab(tab); setShowUpload(true); } function persistConversation(nextMessages: ChatMessage[], forcedId?: string) { if (!nextMessages.length) return forcedId ?? activeConversationId; const id = forcedId ?? activeConversationId ?? crypto.randomUUID(); const now = Date.now(); const existing = conversations.find((conversation) => conversation.id === id); const entry: Conversation = { id, messages: nextMessages, updatedAt: now, createdAt: existing?.createdAt ?? now, repository: selectedRepo?.fullName ?? importedProject?.name }; setActiveConversationId(id); setConversations((previous) => { const existing = previous.find((conversation) => conversation.id === id); const next = upsertConversation(previous, existing?.title ? { ...entry, title: existing.title } : entry, 30); const key = accountStorageKey(historyKey, authUserId); if (key) window.localStorage.setItem(key, JSON.stringify(next)); return next; }); return id; } @@ -125,5 +126,5 @@ export default function Home() { const activePanel = active === 'Projects' ? projectView : active === 'Analyses' ? analysisView : active === 'Connectors' ? connectorsView : workspaceContentWithMedia(); function workspaceContentWithMedia() { return <>{workspaceContent}; } - return
DEV DESK
{selectedRepo ? `${selectedRepo.name} / ${selectedRepo.defaultBranch}` : importedProject ? importedProject.name : active === 'Workspace' ? 'Dev workspace' : active} {hasProject && }
AI online setHistoryOpen(true)} onOpenProjects={() => openHub('create')} onNewChat={startNewChat} onAuthUserChange={setAuthUserId} onLogout={clearAccountWorkspace} onGitHubDisconnected={clearRepositoryContext} />
{activePanel}{active === 'Workspace' && inspector}
{historyOpen &&
setHistoryOpen(false)}>
event.stopPropagation()}>
CHAT HISTORY

Your recent chats

Saved only for this signed-in account on this device
{conversations.length ? conversations.map((conversation) =>
{editingConversationId === conversation.id ?
{ event.preventDefault(); saveConversationTitle(conversation); }}> setTitleDraft(event.target.value)} autoFocus aria-label="Conversation name" />
: <>{expandedConversationId === conversation.id &&
Type{conversationDetails(conversation).kind}
Project{conversationDetails(conversation).project}
Messages{conversationDetails(conversation).messageCount} total · {conversationDetails(conversation).userTurns} user turns
Created{new Date(conversationDetails(conversation).createdAt).toLocaleString()}
Updated{new Date(conversationDetails(conversation).updatedAt).toLocaleString()}
Last activity{conversationDetails(conversation).lastActivity}
}}
) :

No saved chats yet. Use New chat to begin.

}
} setShowUpload(false)} selectedRepo={selectedRepo} importedProject={importedProject} onImported={({ repo, project }) => { const repoKey = accountStorageKey(selectedRepoKey, authUserId); const importKey = accountStorageKey(importedProjectKey, authUserId); if (repo) { setSelectedRepo(repo); if (repoKey) window.localStorage.setItem(repoKey, JSON.stringify(repo)); } else { setSelectedRepo(undefined); if (repoKey) window.localStorage.removeItem(repoKey); } const nextImport = project as ImportedProject; setImportedProject(nextImport); if (importKey) window.localStorage.setItem(importKey, JSON.stringify(nextImport)); setAnalysis(undefined); setChatMessages([]); setChatError(''); setActiveConversationId(undefined); setActive('Workspace'); }} onRemoved={(projectId) => { if (importedProject?.id === projectId) clearRepositoryContext(); }} />
; + return
DEV DESK
{selectedRepo ? `${selectedRepo.name} / ${selectedRepo.defaultBranch}` : importedProject ? importedProject.name : active === 'Workspace' ? 'Dev workspace' : active} {hasProject && }
AI online setHistoryOpen(true)} onOpenProjects={() => openHub('create')} onNewChat={startNewChat} onAuthUserChange={handleAuthUserChange} onLogout={clearAccountWorkspace} onGitHubDisconnected={clearRepositoryContext} />
{activePanel}{active === 'Workspace' && inspector}
{historyOpen &&
setHistoryOpen(false)}>
event.stopPropagation()}>
CHAT HISTORY

Your recent chats

Saved only for this signed-in account on this device
{conversations.length ? conversations.map((conversation) =>
{editingConversationId === conversation.id ?
{ event.preventDefault(); saveConversationTitle(conversation); }}> setTitleDraft(event.target.value)} autoFocus aria-label="Conversation name" />
: <>{expandedConversationId === conversation.id &&
Type{conversationDetails(conversation).kind}
Project{conversationDetails(conversation).project}
Messages{conversationDetails(conversation).messageCount} total · {conversationDetails(conversation).userTurns} user turns
Created{new Date(conversationDetails(conversation).createdAt).toLocaleString()}
Updated{new Date(conversationDetails(conversation).updatedAt).toLocaleString()}
Last activity{conversationDetails(conversation).lastActivity}
}}
) :

No saved chats yet. Use New chat to begin.

}
} setShowUpload(false)} selectedRepo={selectedRepo} importedProject={importedProject} onImported={({ repo, project }) => { const repoKey = accountStorageKey(selectedRepoKey, authUserId); const importKey = accountStorageKey(importedProjectKey, authUserId); if (repo) { setSelectedRepo(repo); if (repoKey) window.localStorage.setItem(repoKey, JSON.stringify(repo)); } else { setSelectedRepo(undefined); if (repoKey) window.localStorage.removeItem(repoKey); } const nextImport = project as ImportedProject; setImportedProject(nextImport); if (importKey) window.localStorage.setItem(importKey, JSON.stringify(nextImport)); setAnalysis(undefined); setChatMessages([]); setChatError(''); setActiveConversationId(undefined); setActive('Workspace'); }} onRemoved={(projectId) => { if (importedProject?.id === projectId) clearRepositoryContext(); }} />
; } diff --git a/app/voice-call/page.tsx b/app/voice-call/page.tsx index 78abc82..10f9b6d 100644 --- a/app/voice-call/page.tsx +++ b/app/voice-call/page.tsx @@ -24,6 +24,7 @@ export default function VoiceCallPage() { const [notice, setNotice] = useState('Choose a voice style, then start a private AI call.'); const [messages, setMessages] = useState([]); const recognitionRef = useRef(null); + const beginListeningRef = useRef<() => void>(() => undefined); const activeRef = useRef(false); const busyRef = useRef(false); const voicesRef = useRef([]); @@ -89,7 +90,7 @@ export default function VoiceCallPage() { const finish = (nextNotice: string, restartListening: boolean) => { if (settled) return; settled = true; setSpeaking(false); busyRef.current = false; setNotice(nextNotice); - if (restartListening && activeRef.current) window.setTimeout(beginListening, 280); + if (restartListening && activeRef.current) window.setTimeout(() => beginListeningRef.current(), 280); }; const speakReply = () => { const utterance = new SpeechSynthesisUtterance(speechText); @@ -107,7 +108,7 @@ export default function VoiceCallPage() { }, 1_400); }; engine.cancel(); engine.resume(); window.setTimeout(speakReply, 40); - } catch (error) { busyRef.current = false; setNotice(error instanceof Error ? error.message : 'DevDesk could not respond. Try again.'); if (activeRef.current) window.setTimeout(beginListening, 400); } finally { setThinking(false); } + } catch (error) { busyRef.current = false; setNotice(error instanceof Error ? error.message : 'DevDesk could not respond. Try again.'); if (activeRef.current) window.setTimeout(() => beginListeningRef.current(), 400); } finally { setThinking(false); } }; recognition.onerror = (event) => { recognitionRef.current = null; setListening(false); setNotice(event.error === 'not-allowed' ? 'Microphone is blocked. Allow mic permission in Chrome settings, then tap the mic.' : 'DevDesk did not hear that. Tap the microphone and speak again.'); }; recognition.onend = () => { recognitionRef.current = null; setListening(false); }; @@ -115,7 +116,8 @@ export default function VoiceCallPage() { try { recognition.start(); setListening(true); setNotice('Listening… speak naturally.'); } catch { recognitionRef.current = null; setNotice('Microphone could not start. Try again.'); } }, [callLanguage, muted, selectedVoiceName, style]); - useEffect(() => { if (callActive && !listening && !speaking && !thinking && !muted) beginListening(); }, [beginListening, callActive, listening, muted, speaking, thinking]); + useEffect(() => { beginListeningRef.current = beginListening; }, [beginListening]); + useEffect(() => { if (callActive && !listening && !speaking && !thinking && !muted) window.queueMicrotask(() => beginListeningRef.current()); }, [callActive, listening, muted, speaking, thinking]); function startCall() { if (!('speechSynthesis' in window)) { setNotice('Speech playback is not supported by this browser. Open this page in Chrome or Samsung Internet.'); return; } const engine = window.speechSynthesis; const welcome = new SpeechSynthesisUtterance(voicePreviewText(callLanguage())); welcome.lang = callLanguage(); const chosen = selectDeviceVoice(voicesRef.current, welcome.lang, style, selectedVoiceName); if (chosen) welcome.voice = chosen; welcome.rate = style === 'senior' ? 0.86 : style === 'girl' || style === 'boy' ? 1.08 : 0.96; welcome.onerror = () => setNotice('Voice playback is blocked. Tap Test voice, turn up media volume, then start again.'); engine.cancel(); engine.resume(); engine.speak(welcome); activeRef.current = true; setCallActive(true); setNotice('DevDesk call connected — you should hear a short welcome now.'); } function endCall() { activeRef.current = false; busyRef.current = false; recognitionRef.current?.stop(); recognitionRef.current = null; window.speechSynthesis?.cancel(); setListening(false); setSpeaking(false); setThinking(false); setCallActive(false); setNotice('Call ended. Start a new call whenever you are ready.'); }