diff --git a/drizzle/0006_project_hierarchy.sql b/drizzle/0006_project_hierarchy.sql new file mode 100644 index 0000000..e6c354a --- /dev/null +++ b/drizzle/0006_project_hierarchy.sql @@ -0,0 +1,15 @@ +CREATE TABLE `project_new` ( + `id` text PRIMARY KEY NOT NULL, + `name` text NOT NULL, + `directory_key` text, + `user_created` integer DEFAULT 0 NOT NULL, + `created_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) NOT NULL, + CONSTRAINT `project_new_directory_key_unique` UNIQUE(`directory_key`) +); +--> statement-breakpoint +INSERT INTO `project_new` (`id`, `name`, `directory_key`, `user_created`, `created_at`) + SELECT `id`, `name`, `directory_key`, 0, `created_at` FROM `project`; +--> statement-breakpoint +DROP TABLE `project`; +--> statement-breakpoint +ALTER TABLE `project_new` RENAME TO `project`; diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index f8bb545..71bdeba 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1744416000000, "tag": "0005_projects", "breakpoints": true + }, + { + "idx": 6, + "version": "6", + "when": 1744502400000, + "tag": "0006_project_hierarchy", + "breakpoints": true } ] } diff --git a/src/client/HomeScreen.tsx b/src/client/HomeScreen.tsx index b747a14..db87fa0 100644 --- a/src/client/HomeScreen.tsx +++ b/src/client/HomeScreen.tsx @@ -137,6 +137,24 @@ export function HomeScreen() { const statusBySession = useAgentStore((s) => s.statusBySession) const setActiveProjectKey = useAgentStore((s) => s.setActiveProjectKey) const setAppView = useAgentStore((s) => s.setAppView) + const createProject = useAgentStore((s) => s.createProject) + const [creating, setCreating] = useState(false) + const [newName, setNewName] = useState('') + + async function handleCreateProject() { + const name = newName.trim() + if (!name) return + setCreating(true) + try { + const proj = await createProject(name) + setNewName('') + enterProject(proj.id) + } catch (err) { + console.error('[HomeScreen] create project failed', err) + } finally { + setCreating(false) + } + } const projectStats = projects.map((project) => { const projectSessions = sessions.filter((s) => s.projectId === project.id) @@ -190,21 +208,56 @@ export function HomeScreen() { {projects.length} project{projects.length !== 1 ? 's' : ''}, {sessions.length} session{sessions.length !== 1 ? 's' : ''} - +
+ setNewName(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') void handleCreateProject() }} + style={{ + background: '#1e293b', + border: '1px solid #334155', + borderRadius: 8, + color: '#e2e8f0', + fontSize: 12, + padding: '6px 10px', + outline: 'none', + width: 180, + }} + /> + + +
{/* Project grid */} @@ -226,8 +279,8 @@ export function HomeScreen() { gap: 12, }} > - No projects yet - Sessions will appear here once opencode is connected + No projects yet + Create a project to organize your agent sessions ) : (
{ if (session?.id) setSelectedSession(session.id) diff --git a/src/client/store/agentStore.test.ts b/src/client/store/agentStore.test.ts index 46a48cf..cd438c2 100644 --- a/src/client/store/agentStore.test.ts +++ b/src/client/store/agentStore.test.ts @@ -193,3 +193,38 @@ describe('applyEvent — todo.updated', () => { ]) }) }) + +describe('project filtering — activeProjectKey', () => { + beforeEach(resetStore) + + function sessionWithProject(id: string, projectId: string) { + return { ...session(id), projectId } + } + + it('shows all sessions when activeProjectKey is null', () => { + useAgentStore.getState().applySessionTree({ + sessions: [sessionWithProject('a', 'p1'), sessionWithProject('b', 'p2')], + }) + expect(useAgentStore.getState().nodes).toHaveLength(2) + }) + + it('filters to only matching project sessions when activeProjectKey is set', () => { + useAgentStore.getState().applySessionTree({ + sessions: [sessionWithProject('a', 'p1'), sessionWithProject('b', 'p2'), sessionWithProject('c', 'p1')], + }) + useAgentStore.getState().setActiveProjectKey('p1') + const nodeIds = useAgentStore.getState().nodes.map((n) => n.id) + expect(nodeIds.sort()).toEqual(['a', 'c']) + }) + + it('returns to all sessions when activeProjectKey is cleared', () => { + useAgentStore.getState().applySessionTree({ + sessions: [sessionWithProject('a', 'p1'), sessionWithProject('b', 'p2')], + }) + useAgentStore.getState().setActiveProjectKey('p1') + expect(useAgentStore.getState().nodes).toHaveLength(1) + + useAgentStore.getState().setActiveProjectKey(null) + expect(useAgentStore.getState().nodes).toHaveLength(2) + }) +}) diff --git a/src/client/store/agentStore.ts b/src/client/store/agentStore.ts index 252f54a..6353240 100644 --- a/src/client/store/agentStore.ts +++ b/src/client/store/agentStore.ts @@ -20,7 +20,8 @@ export type ActiveProjectKey = string | null export type Project = { id: string name: string - directoryKey: string + directoryKey: string | null + userCreated: boolean createdAt: string } @@ -104,7 +105,7 @@ type TreePayload = { compat?: CompatInfo | null relations?: SessionRelation[] taskInvocations?: TaskInvocation[] - projects?: Array<{ id: string; name: string; directory_key: string; created_at: string }> + projects?: Array<{ id: string; name: string; directory_key: string | null; user_created: number; created_at: string }> } type AgentEvent = { @@ -499,6 +500,26 @@ function buildGraph( } } +function rebuildGraph(state: { + sessions: SessionInfo[] + viewMode: ViewMode + statusBySession: Record + lastActivityBySession: Record + relations: SessionRelation[] + pendingPermissions: Record + pendingQuestions: Record + taskInvocations: TaskInvocation[] + activeProjectKey: string | null +}) { + const filtered = state.activeProjectKey + ? state.sessions.filter((s) => s.projectId === state.activeProjectKey) + : state.sessions + return buildGraph( + filtered, state.viewMode, state.statusBySession, state.lastActivityBySession, + state.relations, state.pendingPermissions, state.pendingQuestions, state.taskInvocations, + ) +} + type AgentStore = { sessions: SessionInfo[] statusBySession: Record @@ -527,6 +548,7 @@ type AgentStore = { setViewMode: (mode: ViewMode) => void setAppView: (view: AppView) => void setActiveProjectKey: (key: string | null) => void + createProject: (name: string, directory?: string) => Promise<{ id: string }> setPendingScrollToSessionId: (id: string | null) => void onNodesChange: (changes: NodeChange[]) => void pinNode: (sessionId: string, position: { x: number; y: number }) => void @@ -579,13 +601,29 @@ export const useAgentStore = create((set, get) => ({ setSelectedSession: (id) => set({ selectedSessionId: id }), setSubtaskTargetSession: (id) => set({ subtaskTargetSessionId: id }), setAppView: (view) => set({ appView: view }), - setActiveProjectKey: (key) => set({ activeProjectKey: key }), + setActiveProjectKey: (key) => + set((state) => ({ + activeProjectKey: key, + ...rebuildGraph({ ...state, activeProjectKey: key }), + })), + createProject: async (name, directory) => { + const res = await fetch('/api/project', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, directory }), + }) + if (!res.ok) throw new Error(`${res.status} ${res.statusText}`) + const proj = await res.json() + const treeRes = await fetch('/api/tree') + if (treeRes.ok) get().applySessionTree(await treeRes.json()) + return proj + }, setPendingScrollToSessionId: (id) => set({ pendingScrollToSessionId: id }), setViewMode: (mode) => set((state) => ({ viewMode: mode, - ...buildGraph(state.sessions, mode, state.statusBySession, state.lastActivityBySession, state.relations, state.pendingPermissions, state.pendingQuestions, state.taskInvocations), + ...rebuildGraph({ ...state, viewMode: mode }), })), onNodesChange: (changes) => @@ -611,22 +649,26 @@ export const useAgentStore = create((set, get) => ({ return { sessions, - ...buildGraph(sessions, state.viewMode, state.statusBySession, state.lastActivityBySession, state.relations, state.pendingPermissions, state.pendingQuestions, state.taskInvocations), + ...rebuildGraph({ ...state, sessions }), } }), applySessionTree: ({ sessions, statusBySession = {}, compat = null, relations = [], taskInvocations = [], projects }) => - set((state) => ({ - sessions, - statusBySession: { ...state.statusBySession, ...statusBySession }, - compat, - relations, - taskInvocations, - projects: projects - ? projects.map((p) => ({ id: p.id, name: p.name, directoryKey: p.directory_key, createdAt: p.created_at })) - : state.projects, - ...buildGraph(sessions, state.viewMode, { ...state.statusBySession, ...statusBySession }, state.lastActivityBySession, relations, state.pendingPermissions, state.pendingQuestions, taskInvocations), - })), + set((state) => { + const mergedStatus = { ...state.statusBySession, ...statusBySession } + const newProjects = projects + ? projects.map((p) => ({ id: p.id, name: p.name, directoryKey: p.directory_key ?? null, userCreated: Boolean(p.user_created), createdAt: p.created_at })) + : state.projects + return { + sessions, + statusBySession: mergedStatus, + compat, + relations, + taskInvocations, + projects: newProjects, + ...rebuildGraph({ ...state, sessions, statusBySession: mergedStatus, relations, taskInvocations }), + } + }), applyEvent: (event) => { const properties = event.properties @@ -687,7 +729,7 @@ export const useAgentStore = create((set, get) => ({ pendingPermissions, pendingQuestions, lastActivityBySession, - ...buildGraph(state.sessions, state.viewMode, statusBySession, lastActivityBySession, state.relations, pendingPermissions, pendingQuestions, state.taskInvocations), + ...rebuildGraph({ ...state, statusBySession, lastActivityBySession, pendingPermissions, pendingQuestions }), } }) return @@ -707,7 +749,7 @@ export const useAgentStore = create((set, get) => ({ } return { lastActivityBySession, - ...buildGraph(state.sessions, state.viewMode, state.statusBySession, lastActivityBySession, state.relations, state.pendingPermissions, state.pendingQuestions, state.taskInvocations), + ...rebuildGraph({ ...state, lastActivityBySession }), } }) } @@ -727,7 +769,7 @@ export const useAgentStore = create((set, get) => ({ return { sessions, - ...buildGraph(sessions, state.viewMode, state.statusBySession, state.lastActivityBySession, state.relations, state.pendingPermissions, state.pendingQuestions, state.taskInvocations), + ...rebuildGraph({ ...state, sessions }), } }) return @@ -748,7 +790,7 @@ export const useAgentStore = create((set, get) => ({ return { sessions, - ...buildGraph(sessions, state.viewMode, state.statusBySession, state.lastActivityBySession, state.relations, state.pendingPermissions, state.pendingQuestions, state.taskInvocations), + ...rebuildGraph({ ...state, sessions }), } }) return @@ -817,7 +859,7 @@ export const useAgentStore = create((set, get) => ({ todosBySession, diffBySession, selectedSessionId: state.selectedSessionId === deletedSessionId ? null : state.selectedSessionId, - ...buildGraph(sessions, state.viewMode, statusBySession, lastActivityBySession, state.relations, pendingPermissions, pendingQuestions, state.taskInvocations), + ...rebuildGraph({ ...state, sessions, statusBySession, lastActivityBySession, pendingPermissions, pendingQuestions }), } }) } diff --git a/src/server/db/index.test.ts b/src/server/db/index.test.ts index 6530fc6..2f891c7 100644 --- a/src/server/db/index.test.ts +++ b/src/server/db/index.test.ts @@ -2,7 +2,8 @@ import { beforeEach, describe, expect, it } from 'vitest' import Database from 'better-sqlite3' import { drizzle } from 'drizzle-orm/better-sqlite3' import { and, eq, isNull, or } from 'drizzle-orm' -import { sessionRelation, taskInvocation } from './schema.js' +import { project, sessionRelation, taskInvocation } from './schema.js' +import { randomUUID } from 'crypto' // Build an isolated in-memory DB with just the tables we need function makeTestDb() { @@ -206,3 +207,77 @@ describe('task_invocation collaboration contract', () => { expect(synthesized.includes('ALPHA=RED-734') && synthesized.includes('BETA=BLUE-912')).toBe(true) }) }) + +describe('createProject', () => { + function makeProjectDb() { + const sqlite = new Database(':memory:') + sqlite.exec(` + CREATE TABLE project ( + id TEXT PRIMARY KEY NOT NULL, + name TEXT NOT NULL, + directory_key TEXT UNIQUE, + user_created INTEGER DEFAULT 0 NOT NULL, + created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) NOT NULL + ) + `) + const db = drizzle(sqlite, { schema: { project } }) + return { + create(name: string, directoryKey: string | null) { + if (directoryKey) { + const existing = db.select().from(project).where(eq(project.directory_key, directoryKey)).get() + if (existing) { + db.update(project).set({ name, user_created: 1 }).where(eq(project.id, existing.id)).run() + return db.select().from(project).where(eq(project.id, existing.id)).get()! + } + } + const id = randomUUID() + db.insert(project).values({ id, name, directory_key: directoryKey, user_created: 1 }).run() + return db.select().from(project).where(eq(project.id, id)).get()! + }, + findOrCreate(directoryKey: string) { + const existing = db.select().from(project).where(eq(project.directory_key, directoryKey)).get() + if (existing) return existing + const id = randomUUID() + db.insert(project).values({ id, name: directoryKey, directory_key: directoryKey }).run() + return db.select().from(project).where(eq(project.id, id)).get()! + }, + getAll() { + return db.select().from(project).all() + }, + } + } + + it('creates a project with user_created=1', () => { + const db = makeProjectDb() + const proj = db.create('My Project', null) + expect(proj.name).toBe('My Project') + expect(proj.user_created).toBe(1) + expect(proj.directory_key).toBeNull() + }) + + it('creates a project with directory_key', () => { + const db = makeProjectDb() + const proj = db.create('Apps', 'apps/myapp') + expect(proj.directory_key).toBe('apps/myapp') + expect(proj.user_created).toBe(1) + }) + + it('promotes auto-created project when directory_key matches', () => { + const db = makeProjectDb() + const auto = db.findOrCreate('apps/myapp') + expect(auto.user_created).toBe(0) + + const promoted = db.create('My App', 'apps/myapp') + expect(promoted.id).toBe(auto.id) + expect(promoted.name).toBe('My App') + expect(promoted.user_created).toBe(1) + expect(db.getAll()).toHaveLength(1) + }) + + it('allows multiple projects with null directory_key', () => { + const db = makeProjectDb() + db.create('A', null) + db.create('B', null) + expect(db.getAll()).toHaveLength(2) + }) +}) diff --git a/src/server/db/index.ts b/src/server/db/index.ts index 754aaa9..4e64a79 100644 --- a/src/server/db/index.ts +++ b/src/server/db/index.ts @@ -38,6 +38,19 @@ export function renameProject(id: string, name: string): void { db.update(project).set({ name }).where(eq(project.id, id)).run() } +export function createProject(name: string, directoryKey: string | null): ProjectRow { + if (directoryKey) { + const existing = db.select().from(project).where(eq(project.directory_key, directoryKey)).get() + if (existing) { + db.update(project).set({ name, user_created: 1 }).where(eq(project.id, existing.id)).run() + return db.select().from(project).where(eq(project.id, existing.id)).get()! + } + } + const id = randomUUID() + db.insert(project).values({ id, name, directory_key: directoryKey, user_created: 1 }).run() + return db.select().from(project).where(eq(project.id, id)).get()! +} + export function deleteProject(id: string): void { db.update(canvasNode).set({ project_id: null }).where(eq(canvasNode.project_id, id)).run() db.delete(project).where(eq(project.id, id)).run() diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index 84ff533..4e73d34 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -3,7 +3,8 @@ import { sqliteTable, text, real, integer } from 'drizzle-orm/sqlite-core' export const project = sqliteTable('project', { id: text('id').primaryKey(), name: text('name').notNull(), - directory_key: text('directory_key').notNull().unique(), + directory_key: text('directory_key').unique(), + user_created: integer('user_created').default(0).notNull(), created_at: text('created_at').notNull().default("strftime('%Y-%m-%dT%H:%M:%SZ', 'now')"), }) diff --git a/src/server/routes/project.ts b/src/server/routes/project.ts index d87f677..8f73936 100644 --- a/src/server/routes/project.ts +++ b/src/server/routes/project.ts @@ -1,5 +1,5 @@ import { Hono } from 'hono' -import { getAllProjects, renameProject, deleteProject } from '../db/index.js' +import { getAllProjects, createProject, renameProject, deleteProject } from '../db/index.js' export const projectRouter = new Hono() @@ -7,6 +7,15 @@ projectRouter.get('/api/project', (c) => { return c.json(getAllProjects()) }) +projectRouter.post('/api/project', async (c) => { + const body = await c.req.json<{ name: string; directory?: string }>() + if (typeof body.name !== 'string' || !body.name.trim()) { + return c.json({ error: 'name is required' }, 400) + } + const proj = createProject(body.name.trim(), body.directory?.trim() || null) + return c.json(proj) +}) + projectRouter.patch('/api/project/:id', async (c) => { const id = c.req.param('id') const body = await c.req.json<{ name?: string }>() diff --git a/src/server/routes/session.ts b/src/server/routes/session.ts index 660d924..9873c3a 100644 --- a/src/server/routes/session.ts +++ b/src/server/routes/session.ts @@ -1,13 +1,16 @@ import { Hono } from 'hono' import { randomUUID } from 'crypto' -import { saveSessionFork, saveSessionRelation, cleanupSessionData, getTaskInvocationsForSession, upsertTaskInvocation } from '../db/index.js' +import { saveSessionFork, saveSessionRelation, cleanupSessionData, getTaskInvocationsForSession, upsertTaskInvocation, setCanvasNodeProject } from '../db/index.js' import { opencodeAdapter } from '../opencode/index.js' export const sessionRouter = new Hono() sessionRouter.post('/api/session', async (c) => { - const body = await c.req.json<{ title?: string; parentID?: string; directory?: string }>() + const body = await c.req.json<{ title?: string; parentID?: string; directory?: string; projectId?: string }>() const session = await opencodeAdapter.createSession(body) + if (body.projectId) { + setCanvasNodeProject(session.id, body.projectId) + } return c.json(session) }) diff --git a/src/server/routes/tree.ts b/src/server/routes/tree.ts index e34aaf4..8cc7c87 100644 --- a/src/server/routes/tree.ts +++ b/src/server/routes/tree.ts @@ -1,5 +1,6 @@ import { Hono } from 'hono' import { getAllCanvasNodes, getAllProjects, getAllSessionForks, getAllSessionRelations, getAllTaskInvocations, findOrCreateProject, setCanvasNodeProject } from '../db/index.js' +import type { ProjectRow } from '../db/schema.js' import { opencodeAdapter } from '../opencode/index.js' export const treeRouter = new Hono() @@ -33,7 +34,7 @@ treeRouter.get('/api/tree', async (c) => { const compat = compatResult.status === 'fulfilled' ? compatResult.value : null // Auto-create projects for each unique directory key and assign sessions - const projectByDirectoryKey = new Map() + const projectByDirectoryKey = new Map() for (const session of sessions) { const dirKey = projectGroupFromDirectory(session.directory) if (!projectByDirectoryKey.has(dirKey)) {