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
15 changes: 15 additions & 0 deletions drizzle/0006_project_hierarchy.sql
Original file line number Diff line number Diff line change
@@ -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`;
7 changes: 7 additions & 0 deletions drizzle/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,13 @@
"when": 1744416000000,
"tag": "0005_projects",
"breakpoints": true
},
{
"idx": 6,
"version": "6",
"when": 1744502400000,
"tag": "0006_project_hierarchy",
"breakpoints": true
}
]
}
87 changes: 70 additions & 17 deletions src/client/HomeScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Comment on lines +152 to +154
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The error handling for project creation only logs to the console. If the API call fails (e.g., due to a network issue or a duplicate directory key on the server), the user will see the button stop loading but won't know why the project wasn't created. Consider adding a simple error state or a toast notification to inform the user.

setCreating(false)
}
}

const projectStats = projects.map((project) => {
const projectSessions = sessions.filter((s) => s.projectId === project.id)
Expand Down Expand Up @@ -190,21 +208,56 @@ export function HomeScreen() {
{projects.length} project{projects.length !== 1 ? 's' : ''}, {sessions.length} session{sessions.length !== 1 ? 's' : ''}
</span>
</div>
<button
onClick={viewAll}
style={{
background: 'none',
border: '1px solid #334155',
color: '#94a3b8',
borderRadius: 8,
padding: '6px 14px',
fontSize: 12,
fontWeight: 600,
cursor: 'pointer',
}}
>
View All Sessions
</button>
<div style={{ display: 'flex', gap: 8 }}>
<input
placeholder="New project name"
value={newName}
onChange={(e) => 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,
}}
/>
<button
onClick={() => 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
</button>
<button
onClick={viewAll}
style={{
background: 'none',
border: '1px solid #334155',
color: '#94a3b8',
borderRadius: 8,
padding: '6px 14px',
fontSize: 12,
fontWeight: 600,
cursor: 'pointer',
}}
>
View All
</button>
</div>
</div>

{/* Project grid */}
Expand All @@ -226,8 +279,8 @@ export function HomeScreen() {
gap: 12,
}}
>
<span style={{ color: '#374151', fontSize: 14 }}>No projects yet</span>
<span style={{ color: '#374151', fontSize: 12 }}>Sessions will appear here once opencode is connected</span>
<span style={{ color: '#475569', fontSize: 14 }}>No projects yet</span>
<span style={{ color: '#374151', fontSize: 12 }}>Create a project to organize your agent sessions</span>
</div>
) : (
<div
Expand Down
2 changes: 1 addition & 1 deletion src/client/canvas/AgentCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -322,7 +322,7 @@ export function AgentCanvas() {
fetchJson('/api/session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: title.trim() || undefined }),
body: JSON.stringify({ title: title.trim() || undefined, projectId: activeProjectKey || undefined }),
})
.then((session) => {
if (session?.id) setSelectedSession(session.id)
Expand Down
35 changes: 35 additions & 0 deletions src/client/store/agentStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
84 changes: 63 additions & 21 deletions src/client/store/agentStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -499,6 +500,26 @@ function buildGraph(
}
}

function rebuildGraph(state: {
sessions: SessionInfo[]
viewMode: ViewMode
statusBySession: Record<string, NodeStatus>
lastActivityBySession: Record<string, string>
relations: SessionRelation[]
pendingPermissions: Record<string, unknown>
pendingQuestions: Record<string, unknown>
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<string, NodeStatus>
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -579,13 +601,29 @@ export const useAgentStore = create<AgentStore>((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) =>
Expand All @@ -611,22 +649,26 @@ export const useAgentStore = create<AgentStore>((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
Expand Down Expand Up @@ -687,7 +729,7 @@ export const useAgentStore = create<AgentStore>((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
Expand All @@ -707,7 +749,7 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
}
return {
lastActivityBySession,
...buildGraph(state.sessions, state.viewMode, state.statusBySession, lastActivityBySession, state.relations, state.pendingPermissions, state.pendingQuestions, state.taskInvocations),
...rebuildGraph({ ...state, lastActivityBySession }),
}
})
}
Expand All @@ -727,7 +769,7 @@ export const useAgentStore = create<AgentStore>((set, get) => ({

return {
sessions,
...buildGraph(sessions, state.viewMode, state.statusBySession, state.lastActivityBySession, state.relations, state.pendingPermissions, state.pendingQuestions, state.taskInvocations),
...rebuildGraph({ ...state, sessions }),
}
})
return
Expand All @@ -748,7 +790,7 @@ export const useAgentStore = create<AgentStore>((set, get) => ({

return {
sessions,
...buildGraph(sessions, state.viewMode, state.statusBySession, state.lastActivityBySession, state.relations, state.pendingPermissions, state.pendingQuestions, state.taskInvocations),
...rebuildGraph({ ...state, sessions }),
}
})
return
Expand Down Expand Up @@ -817,7 +859,7 @@ export const useAgentStore = create<AgentStore>((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 }),
}
})
}
Expand Down
Loading
Loading