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' : ''}
-
- View All Sessions
-
+
+ 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,
+ }}
+ />
+ void handleCreateProject()}
+ disabled={creating || !newName.trim()}
+ style={{
+ background: '#1d4ed8',
+ color: '#eff6ff',
+ border: 'none',
+ borderRadius: 8,
+ padding: '6px 14px',
+ fontSize: 12,
+ fontWeight: 700,
+ cursor: newName.trim() ? 'pointer' : 'default',
+ opacity: newName.trim() ? 1 : 0.5,
+ }}
+ >
+ + New Project
+
+
+ View All
+
+
{/* 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)) {