Skip to content
Open
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
2 changes: 2 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,13 @@ import PerformanceLogs from './pages/PerformanceLogs';
import SettingsPage from './pages/Settings';
import NotFound from './pages/NotFound';
import { eventBus } from './events/EventBus';
import { useProjectStore } from './stores/project';

export default function App(): JSX.Element {
const qc = useQueryClient();
useEffect(() => {
eventBus.attachQueryClient(qc);
eventBus.setProjectIdProvider(() => useProjectStore.getState().currentProjectId);
eventBus.connect();
return () => eventBus.disconnect();
}, [qc]);
Expand Down
14 changes: 14 additions & 0 deletions frontend/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,20 @@ async function handle<T>(res: Response): Promise<T> {

const base = '';

export function buildQuery(
path: string,
params: Record<string, string | number | null | undefined>,
): string {
const entries = Object.entries(params).filter(
([, v]) => v !== undefined && v !== null && v !== '',
);
if (entries.length === 0) return path;
const qs = new URLSearchParams();
for (const [k, v] of entries) qs.set(k, String(v));
const sep = path.includes('?') ? '&' : '?';
return `${path}${sep}${qs.toString()}`;
}

export const api = {
get: <T>(path: string) =>
fetch(`${base}${path}`, { headers: authHeaders() }).then((r) => handle<T>(r)),
Expand Down
9 changes: 9 additions & 0 deletions frontend/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,15 @@ export interface PerformanceLog {
logged_at: string;
}

export interface Project {
id: string;
name: string;
path: string;
repo_url: string | null;
created_at: string;
updated_at: string;
}

export interface Stats {
tasks: { total: number; open: number; in_progress: number; blocked: number; done: number };
sessions: { total: number; active: number };
Expand Down
96 changes: 96 additions & 0 deletions frontend/src/components/CreateProjectModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';

import { api } from '../api/client';
import type { Project } from '../api/types';
import { useProjectStore } from '../stores/project';

interface Props {
onClose: () => void;
}

export default function CreateProjectModal({ onClose }: Props): JSX.Element {
const qc = useQueryClient();
const setCurrent = useProjectStore((s) => s.setCurrentProjectId);
const [name, setName] = useState('');
const [path, setPath] = useState('');
const [repoUrl, setRepoUrl] = useState('');

const create = useMutation({
mutationFn: (body: { name: string; path: string; repo_url: string | null }) =>
api.post<Project>('/api/projects', body),
onSuccess: (project) => {
qc.invalidateQueries({ queryKey: ['projects'] });
setCurrent(project.id);
onClose();
Comment on lines +23 to +25
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Race condition: useProjects auto-select overrides newly created project selection

After creating a project in CreateProjectModal, the onSuccess handler calls invalidateQueries (async refetch), then setCurrent(project.id). The useProjects hook at frontend/src/hooks/useProjects.ts:16-21 has a useEffect that depends on [query.data, currentId, setCurrent]. When currentId changes to the new project's ID, the effect fires — but query.data is still the stale list (the refetch hasn't resolved yet). Since the new project isn't in the stale list, !list.some((p) => p.id === currentId) evaluates to true, and setCurrent(list[0].id) immediately reverts the selection to the first project. The user creates a project expecting to switch to it, but ends up on the old first project instead.

Prompt for agents
The race condition occurs because invalidateQueries triggers an async refetch, but setCurrent sets the new project ID synchronously. Before the refetch completes, the useProjects effect (frontend/src/hooks/useProjects.ts:16-21) detects that the new currentId is not in the stale query.data and resets it to list[0].id.

Two possible fixes:

1. In CreateProjectModal.tsx onSuccess, optimistically update the React Query cache before setting the current ID:
   qc.setQueryData(['projects'], (old: Project[] | undefined) => [...(old ?? []), project]);
   setCurrent(project.id);
   qc.invalidateQueries({ queryKey: ['projects'] });
   This ensures the project list cache includes the new project before the effect can fire.

2. In useProjects.ts, change the auto-select effect to only activate when currentId is null (not when it is set but missing from the list). Then add a separate mechanism (e.g. check query.isFetched or query.isStale) to handle genuinely deleted projects. This avoids the effect fighting with explicit user selections.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed — caught the same race in the smoke-test recording (sidebar stayed on Alpha after creating Bravo). Fixed in #3 using both suggestions together: qc.setQueryData seeds the cache synchronously, and useProjects only defaults when currentId == null. Belt-and-suspenders.

},
});

const submit = (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim() || !path.trim()) return;
create.mutate({
name: name.trim(),
path: path.trim(),
repo_url: repoUrl.trim() || null,
});
};

return (
<div className="fixed inset-0 z-20 flex items-center justify-center bg-slate-900/40 p-4">
<form
onSubmit={submit}
className="w-full max-w-md rounded-lg bg-white p-5 shadow-xl"
>
<h2 className="mb-3 text-lg font-semibold">New project</h2>
<label className="mb-3 block text-sm">
Name
<input
className="mt-1 w-full rounded border border-slate-300 px-2 py-1.5 text-sm"
value={name}
onChange={(e) => setName(e.target.value)}
autoFocus
/>
</label>
<label className="mb-3 block text-sm">
Path
<input
className="mt-1 w-full rounded border border-slate-300 px-2 py-1.5 text-sm"
placeholder="/home/you/projects/acme"
value={path}
onChange={(e) => setPath(e.target.value)}
/>
</label>
<label className="mb-4 block text-sm">
Repo URL <span className="text-slate-400">(optional)</span>
<input
className="mt-1 w-full rounded border border-slate-300 px-2 py-1.5 text-sm"
value={repoUrl}
onChange={(e) => setRepoUrl(e.target.value)}
/>
</label>
{create.isError && (
<p className="mb-3 text-sm text-rose-600">
{(create.error as Error).message}
</p>
)}
<div className="flex justify-end gap-2">
<button
type="button"
className="rounded px-3 py-1.5 text-sm text-slate-600 hover:bg-slate-100"
onClick={onClose}
>
Cancel
</button>
<button
type="submit"
className="btn-primary"
disabled={create.isPending || !name.trim() || !path.trim()}
>
{create.isPending ? 'Creating…' : 'Create'}
</button>
</div>
</form>
</div>
);
}
4 changes: 3 additions & 1 deletion frontend/src/components/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
type LucideIcon,
} from 'lucide-react';
import { useConnectionStatus } from '../hooks/useConnectionStatus';
import ProjectSelector from './ProjectSelector';

interface NavItem {
to: string;
Expand Down Expand Up @@ -44,7 +45,8 @@ export default function Layout(): JSX.Element {
<span className="inline-block h-6 w-6 rounded-md bg-brand-600" />
OBSMCP
</div>
<nav className="flex-1 px-2 pb-4">
<ProjectSelector />
<nav className="flex-1 px-2 pb-4 pt-3">
{NAV.map((item) => (
<NavLink
key={item.to}
Expand Down
66 changes: 66 additions & 0 deletions frontend/src/components/ProjectSelector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { useState } from 'react';
import { ChevronsUpDown, FolderGit2, Plus } from 'lucide-react';

import { useProjects } from '../hooks/useProjects';
import { useProjectStore } from '../stores/project';
import CreateProjectModal from './CreateProjectModal';

export default function ProjectSelector(): JSX.Element {
const { data: projects, isLoading } = useProjects();
const currentId = useProjectStore((s) => s.currentProjectId);
const setCurrent = useProjectStore((s) => s.setCurrentProjectId);
const [open, setOpen] = useState(false);
const [creating, setCreating] = useState(false);

const current = projects?.find((p) => p.id === currentId) ?? null;

return (
<div className="relative border-b border-slate-200 px-3 py-3">
<button
type="button"
className="flex w-full items-center gap-2 rounded-md border border-slate-200 bg-slate-50 px-2 py-2 text-left text-sm hover:bg-slate-100"
onClick={() => setOpen((v) => !v)}
>
<FolderGit2 size={16} className="text-brand-600" />
<span className="flex-1 truncate">
{isLoading ? 'Loading…' : current?.name ?? 'Select project'}
</span>
<ChevronsUpDown size={14} className="text-slate-400" />
</button>
{open && (
<div className="absolute left-3 right-3 top-full z-10 mt-1 max-h-72 overflow-auto rounded-md border border-slate-200 bg-white shadow-lg">
{(projects ?? []).map((p) => (
<button
key={p.id}
type="button"
onClick={() => {
setCurrent(p.id);
setOpen(false);
}}
className={`block w-full truncate px-3 py-2 text-left text-sm hover:bg-slate-100 ${
p.id === currentId ? 'bg-brand-50 text-brand-700' : ''
}`}
>
<div className="truncate font-medium">{p.name}</div>
<div className="truncate text-xs text-slate-500">{p.path}</div>
</button>
))}
{(projects ?? []).length === 0 && (
<div className="px-3 py-2 text-xs text-slate-500">No projects yet.</div>
)}
<button
type="button"
onClick={() => {
setOpen(false);
setCreating(true);
}}
className="flex w-full items-center gap-2 border-t border-slate-100 px-3 py-2 text-left text-sm text-brand-700 hover:bg-brand-50"
>
<Plus size={14} /> New project
</button>
</div>
)}
{creating && <CreateProjectModal onClose={() => setCreating(false)} />}
</div>
);
}
18 changes: 18 additions & 0 deletions frontend/src/events/EventBus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,11 +79,16 @@ export class EventBus {
private queryClient: QueryClient | null = null;
public connected = false;
private statusListeners = new Set<(c: boolean) => void>();
private projectIdProvider: () => string | null = () => null;

attachQueryClient(qc: QueryClient): void {
this.queryClient = qc;
}

setProjectIdProvider(fn: () => string | null): void {
this.projectIdProvider = fn;
}

connect(): void {
if (this.es) return;
const token = getApiToken();
Expand Down Expand Up @@ -139,6 +144,19 @@ export class EventBus {
} catch {
return;
}
const currentProjectId = this.projectIdProvider();
const eventProjectId =
typeof event.payload === 'object' && event.payload
? (event.payload as { project_id?: string | null }).project_id ?? null
: null;
const scoped = eventProjectId !== null;
if (
scoped &&
currentProjectId !== null &&
eventProjectId !== currentProjectId
) {
return;
}
this.listeners.forEach((l) => l(event));
const keys = EVENT_TO_QUERY[event.type] ?? [];
if (this.queryClient) {
Expand Down
25 changes: 25 additions & 0 deletions frontend/src/hooks/useProjects.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';

import { api } from '../api/client';
import type { Project } from '../api/types';
import { useProjectStore } from '../stores/project';

export function useProjects() {
const setCurrent = useProjectStore((s) => s.setCurrentProjectId);
const currentId = useProjectStore((s) => s.currentProjectId);
const query = useQuery<Project[]>({
queryKey: ['projects'],
queryFn: () => api.get<Project[]>('/api/projects'),
});

useEffect(() => {
const list = query.data;
if (!list || list.length === 0) return;
if (!currentId || !list.some((p) => p.id === currentId)) {
setCurrent(list[0].id);
}
}, [query.data, currentId, setCurrent]);

return query;
}
11 changes: 7 additions & 4 deletions frontend/src/pages/Blockers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,24 @@ import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import PageHeader from '../components/PageHeader';
import EmptyState from '../components/EmptyState';
import { api } from '../api/client';
import { api, buildQuery } from '../api/client';
import type { Blocker } from '../api/types';
import { useCurrentProjectId } from '../stores/project';

export default function BlockersPage(): JSX.Element {
const qc = useQueryClient();
const projectId = useCurrentProjectId();
const [description, setDescription] = useState('');
const [severity, setSeverity] = useState<Blocker['severity']>('medium');
const [resolution, setResolution] = useState<Record<string, string>>({});

const blockers = useQuery<Blocker[]>({
queryKey: ['blockers'],
queryFn: () => api.get<Blocker[]>('/api/blockers'),
queryKey: ['blockers', { projectId }],
queryFn: () => api.get<Blocker[]>(buildQuery('/api/blockers', { project_id: projectId })),
});
const create = useMutation({
mutationFn: (body: Partial<Blocker>) => api.post<Blocker>('/api/blockers', body),
mutationFn: (body: Partial<Blocker>) =>
api.post<Blocker>('/api/blockers', { ...body, project_id: projectId }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['blockers'] }),
});
const resolve = useMutation({
Expand Down
12 changes: 8 additions & 4 deletions frontend/src/pages/CodeAtlas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,19 @@ import { Play } from 'lucide-react';

import PageHeader from '../components/PageHeader';
import EmptyState from '../components/EmptyState';
import { api } from '../api/client';
import { api, buildQuery } from '../api/client';
import type { CodeAtlasFile, CodeAtlasScan } from '../api/types';
import { useCurrentProjectId } from '../stores/project';

const COLORS = ['#5b71ff', '#f97316', '#22c55e', '#ef4444', '#a855f7', '#eab308', '#06b6d4'];

export default function CodeAtlasPage(): JSX.Element {
const qc = useQueryClient();
const projectId = useCurrentProjectId();
const scans = useQuery<CodeAtlasScan[]>({
queryKey: ['code-atlas', 'scans'],
queryFn: () => api.get<CodeAtlasScan[]>('/api/code-atlas'),
queryKey: ['code-atlas', 'scans', { projectId }],
queryFn: () =>
api.get<CodeAtlasScan[]>(buildQuery('/api/code-atlas', { project_id: projectId })),
});
const latestScan = scans.data?.[0];
const filesQuery = useQuery<{ files: CodeAtlasFile[] }>({
Expand All @@ -36,7 +39,8 @@ export default function CodeAtlasPage(): JSX.Element {
api.get<{ files: CodeAtlasFile[] }>(`/api/code-atlas/scan/${latestScan!.id}/files?per_page=500`),
});
const start = useMutation({
mutationFn: () => api.post<CodeAtlasScan>('/api/code-atlas/scan', {}),
mutationFn: () =>
api.post<CodeAtlasScan>('/api/code-atlas/scan', { project_id: projectId }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['code-atlas'] }),
});

Expand Down
Loading