Skip to content

Commit 132c84b

Browse files
committed
v1.9.5: fix epic→task crash, inline editing, board column height
1 parent 4f97d90 commit 132c84b

9 files changed

Lines changed: 188 additions & 53 deletions

File tree

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@graphmemory/server",
3-
"version": "1.9.4",
3+
"version": "1.9.5",
44
"description": "MCP server for semantic graph memory from markdown files",
55
"main": "dist/cli/index.js",
66
"bin": {

site/src/pages/changelog.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,24 @@ description: Graph Memory release history and version changes.
55

66
# Changelog
77

8+
## v1.9.5
9+
10+
**March 2026**
11+
12+
### Fixes
13+
14+
- **Epic→Task navigation crash**`statusLabel()`/`priorityLabel()` threw `Cannot read properties of undefined (reading 'toUpperCase')` when task relations (`blockedBy`, `blocks`, `related`) lacked `status` field. Server now returns `status` for all relation types; UI guards against undefined
15+
- **Epic→Task breadcrumbs** — navigating from epic to task now passes `?from=epic&epicId=` so breadcrumbs show full path: Tasks → Epics → Epic Name → Task
16+
17+
### Improved
18+
19+
- **Inline status/priority editing** — task detail view replaces "Move to" dropdown with badge-style select (matching list view); epic detail view now has inline status and priority selects
20+
- **Inline assignee editing** — task detail view shows assignee as a select dropdown (like epic field), visible even when unassigned
21+
- **Board column height** — all board columns now stretch to match the tallest column, making drag & drop into empty columns easy
22+
- **Full-width selects** — Epic and Assignee selects on task detail stretch to full width
23+
24+
---
25+
826
## v1.9.4
927

1028
**March 2026**

src/graphs/task.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -357,9 +357,9 @@ export function getTask(
357357
taskId: string,
358358
): (TaskEntry & {
359359
subtasks: Array<{ id: string; title: string; status: TaskStatus }>;
360-
blockedBy: Array<{ id: string; title: string }>;
361-
blocks: Array<{ id: string; title: string }>;
362-
related: Array<{ id: string; title: string }>;
360+
blockedBy: Array<{ id: string; title: string; status: TaskStatus }>;
361+
blocks: Array<{ id: string; title: string; status: TaskStatus }>;
362+
related: Array<{ id: string; title: string; status: TaskStatus }>;
363363
crossLinks: CrossLinkEntry[];
364364
}) | null {
365365
if (!graph.hasNode(taskId)) return null;
@@ -368,9 +368,9 @@ export function getTask(
368368

369369
const attrs = graph.getNodeAttributes(taskId);
370370
const subtasks: Array<{ id: string; title: string; status: TaskStatus }> = [];
371-
const blockedBy: Array<{ id: string; title: string }> = [];
372-
const blocks: Array<{ id: string; title: string }> = [];
373-
const related: Array<{ id: string; title: string }> = [];
371+
const blockedBy: Array<{ id: string; title: string; status: TaskStatus }> = [];
372+
const blocks: Array<{ id: string; title: string; status: TaskStatus }> = [];
373+
const related: Array<{ id: string; title: string; status: TaskStatus }> = [];
374374
const crossLinks: CrossLinkEntry[] = [];
375375

376376
// Incoming edges: subtask_of (child → this) means child is a subtask
@@ -393,9 +393,9 @@ export function getTask(
393393
subtasks.push({ id: source, title: srcAttrs.title, status: srcAttrs.status });
394394
} else if (edgeAttrs.kind === 'blocks') {
395395
// source blocks this task
396-
blockedBy.push({ id: source, title: srcAttrs.title });
396+
blockedBy.push({ id: source, title: srcAttrs.title, status: srcAttrs.status });
397397
} else if (edgeAttrs.kind === 'related_to') {
398-
related.push({ id: source, title: srcAttrs.title });
398+
related.push({ id: source, title: srcAttrs.title, status: srcAttrs.status });
399399
}
400400
});
401401

@@ -418,10 +418,10 @@ export function getTask(
418418
if (edgeAttrs.kind === 'subtask_of') {
419419
// this task is a subtask of target — skip, handled via parent lookup
420420
} else if (edgeAttrs.kind === 'blocks') {
421-
blocks.push({ id: target, title: tgtAttrs.title });
421+
blocks.push({ id: target, title: tgtAttrs.title, status: tgtAttrs.status });
422422
} else if (edgeAttrs.kind === 'related_to') {
423423
if (!related.some(r => r.id === target)) {
424-
related.push({ id: target, title: tgtAttrs.title });
424+
related.push({ id: target, title: tgtAttrs.title, status: tgtAttrs.status });
425425
}
426426
}
427427
});

src/tests/mcp-tasks.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,9 @@ interface TaskResult {
3333
createdAt: number;
3434
updatedAt: number;
3535
subtasks: Array<{ id: string; title: string; status: TaskStatus }>;
36-
blockedBy: Array<{ id: string; title: string }>;
37-
blocks: Array<{ id: string; title: string }>;
38-
related: Array<{ id: string; title: string }>;
36+
blockedBy: Array<{ id: string; title: string; status: TaskStatus }>;
37+
blocks: Array<{ id: string; title: string; status: TaskStatus }>;
38+
related: Array<{ id: string; title: string; status: TaskStatus }>;
3939
}
4040

4141
interface TaskListEntry {

ui/src/entities/task/config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,11 @@ export const PRIORITY_BADGE_COLOR: Record<TaskPriority, 'error' | 'warning' | 'p
3333
};
3434

3535
export function statusLabel(status: TaskStatus): string {
36+
if (!status) return '—';
3637
return COLUMNS.find(c => c.status === status)?.label ?? status.toUpperCase();
3738
}
3839

3940
export function priorityLabel(priority: TaskPriority): string {
41+
if (!priority) return '—';
4042
return priority.toUpperCase().replace('_', ' ');
4143
}

ui/src/pages/epics/[epicId].tsx

Lines changed: 75 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,14 @@ import { useParams, useNavigate } from 'react-router-dom';
33
import {
44
Box, Typography, Button, Alert, CircularProgress,
55
LinearProgress, Paper, Table, TableBody, TableCell, TableHead, TableRow,
6-
alpha, useTheme,
6+
Select, MenuItem, alpha, useTheme,
77
} from '@mui/material';
88
import EditIcon from '@mui/icons-material/Edit';
99
import DeleteIcon from '@mui/icons-material/Delete';
1010
import ViewListIcon from '@mui/icons-material/ViewList';
11-
import { getEpic, deleteEpic, listEpicTasks, type Epic } from '@/entities/epic/index.ts';
11+
import { getEpic, updateEpic, deleteEpic, listEpicTasks, type Epic, type EpicStatus, type EpicPriority } from '@/entities/epic/index.ts';
1212
import type { Task } from '@/entities/task/index.ts';
13-
import { PRIORITY_BADGE_COLOR, priorityLabel, statusLabel } from '@/entities/task/index.ts';
13+
import { PRIORITY_BADGE_COLOR, PRIORITY_COLORS, priorityLabel, statusLabel } from '@/entities/task/index.ts';
1414
import { useCanWrite } from '@/shared/lib/AccessContext.tsx';
1515
import { useWebSocket } from '@/shared/lib/useWebSocket.ts';
1616
import {
@@ -33,6 +33,7 @@ const EPIC_STATUS_BADGE: Record<string, 'primary' | 'warning' | 'success' | 'err
3333
};
3434

3535
function epicStatusLabel(s: string): string {
36+
if (!s) return '—';
3637
return { open: 'OPEN', in_progress: 'IN PROGRESS', done: 'DONE', cancelled: 'CANCELLED' }[s] ?? s.toUpperCase();
3738
}
3839

@@ -138,7 +139,7 @@ export default function EpicDetailPage() {
138139
key={task.id}
139140
hover
140141
sx={{ cursor: 'pointer' }}
141-
onClick={() => navigate(`/${projectId}/tasks/${task.id}`)}
142+
onClick={() => navigate(`/${projectId}/tasks/${task.id}?from=epic&epicId=${epicId}`)}
142143
>
143144
<TableCell>
144145
<Typography variant="body2" fontWeight={500}>{task.title}</Typography>
@@ -190,10 +191,78 @@ export default function EpicDetailPage() {
190191
</Box>
191192
</FieldRow>
192193
<FieldRow label="Status">
193-
<StatusBadge label={epicStatusLabel(epic.status)} color={EPIC_STATUS_BADGE[epic.status] ?? 'neutral'} />
194+
{canWrite ? (
195+
<Select
196+
size="small"
197+
value={epic.status}
198+
onChange={async (e) => {
199+
const s = e.target.value as EpicStatus;
200+
setEpic(prev => prev ? { ...prev, status: s } : prev);
201+
await updateEpic(projectId!, epicId!, { status: s });
202+
refresh();
203+
}}
204+
variant="standard"
205+
disableUnderline
206+
sx={{
207+
bgcolor: alpha(EPIC_STATUS_COLOR[epic.status] ?? '#616161', 0.12),
208+
color: EPIC_STATUS_COLOR[epic.status] ?? '#616161',
209+
fontWeight: 600, fontSize: '0.75rem', borderRadius: '999px',
210+
border: `1px solid ${alpha(EPIC_STATUS_COLOR[epic.status] ?? '#616161', 0.3)}`,
211+
height: 26, minWidth: 70,
212+
'& .MuiSelect-select': { py: '2px', px: 1.2, display: 'flex', alignItems: 'center' },
213+
'& .MuiSelect-icon': { fontSize: '1rem', color: EPIC_STATUS_COLOR[epic.status] ?? '#616161', right: 4 },
214+
'&:before, &:after': { display: 'none' },
215+
}}
216+
>
217+
{(['open', 'in_progress', 'done', 'cancelled'] as const).map(s => (
218+
<MenuItem key={s} value={s}>
219+
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
220+
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: EPIC_STATUS_COLOR[s] }} />
221+
{epicStatusLabel(s)}
222+
</Box>
223+
</MenuItem>
224+
))}
225+
</Select>
226+
) : (
227+
<StatusBadge label={epicStatusLabel(epic.status)} color={EPIC_STATUS_BADGE[epic.status] ?? 'neutral'} />
228+
)}
194229
</FieldRow>
195230
<FieldRow label="Priority">
196-
<StatusBadge label={priorityLabel(epic.priority)} color={PRIORITY_BADGE_COLOR[epic.priority]} />
231+
{canWrite ? (
232+
<Select
233+
size="small"
234+
value={epic.priority}
235+
onChange={async (e) => {
236+
const p = e.target.value as EpicPriority;
237+
setEpic(prev => prev ? { ...prev, priority: p } : prev);
238+
await updateEpic(projectId!, epicId!, { priority: p });
239+
refresh();
240+
}}
241+
variant="standard"
242+
disableUnderline
243+
sx={{
244+
bgcolor: alpha(PRIORITY_COLORS[epic.priority], 0.12),
245+
color: PRIORITY_COLORS[epic.priority],
246+
fontWeight: 600, fontSize: '0.75rem', borderRadius: '999px',
247+
border: `1px solid ${alpha(PRIORITY_COLORS[epic.priority], 0.3)}`,
248+
height: 26, minWidth: 70,
249+
'& .MuiSelect-select': { py: '2px', px: 1.2, display: 'flex', alignItems: 'center' },
250+
'& .MuiSelect-icon': { fontSize: '1rem', color: PRIORITY_COLORS[epic.priority], right: 4 },
251+
'&:before, &:after': { display: 'none' },
252+
}}
253+
>
254+
{(['critical', 'high', 'medium', 'low'] as const).map(p => (
255+
<MenuItem key={p} value={p}>
256+
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
257+
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: PRIORITY_COLORS[p] }} />
258+
{priorityLabel(p)}
259+
</Box>
260+
</MenuItem>
261+
))}
262+
</Select>
263+
) : (
264+
<StatusBadge label={priorityLabel(epic.priority)} color={PRIORITY_BADGE_COLOR[epic.priority]} />
265+
)}
197266
</FieldRow>
198267
<FieldRow label="Tags">
199268
{epic.tags.length > 0 ? <Tags tags={epic.tags} /> : <Typography variant="body2" color="text.secondary"></Typography>}

ui/src/pages/tasks/[taskId].tsx

Lines changed: 75 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { useState, useEffect, useCallback } from 'react';
22
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
33
import {
44
Box, Typography, Button, Alert, CircularProgress, Link,
5-
FormControl, InputLabel, Select, MenuItem, Stack, alpha,
5+
Select, MenuItem, Stack, alpha,
66
} from '@mui/material';
77
import EditIcon from '@mui/icons-material/Edit';
88
import DeleteIcon from '@mui/icons-material/Delete';
@@ -35,6 +35,7 @@ export default function TaskDetailPage() {
3535
const navigate = useNavigate();
3636
const [searchParams] = useSearchParams();
3737
const from = searchParams.get('from');
38+
const epicId = searchParams.get('epicId');
3839
const canWrite = useCanWrite('tasks');
3940
const [task, setTask] = useState<TaskDetail | null>(null);
4041
const [relations, setRelations] = useState<TaskRelation[]>([]);
@@ -83,12 +84,6 @@ export default function TaskDetailPage() {
8384
navigate(`/${projectId}/tasks`);
8485
};
8586

86-
const handleMove = async (status: TaskStatus) => {
87-
if (!projectId || !taskId) return;
88-
await moveTask(projectId, taskId, status);
89-
load();
90-
};
91-
9287
if (loading) {
9388
return <Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>;
9489
}
@@ -123,13 +118,23 @@ export default function TaskDetailPage() {
123118
breadcrumbs={[
124119
{ label: 'Tasks', to: `/${projectId}/tasks` },
125120
...(from === 'board' ? [{ label: 'Board', to: `/${projectId}/tasks/board` }] :
126-
from === 'list' ? [{ label: 'List', to: `/${projectId}/tasks/list` }] : []),
121+
from === 'list' ? [{ label: 'List', to: `/${projectId}/tasks/list` }] :
122+
from === 'epic' && epicId ? [
123+
{ label: 'Epics', to: `/${projectId}/tasks/epics` },
124+
{ label: epics.find(e => e.id === epicId)?.title ?? 'Epic', to: `/${projectId}/tasks/epics/${epicId}` },
125+
] : []),
127126
{ label: task.title },
128127
]}
129128
actions={
130129
canWrite ? (
131130
<>
132-
<Button variant="contained" color="success" startIcon={<EditIcon />} onClick={() => navigate(`/${projectId}/tasks/${taskId}/edit${from ? `?from=${from}` : ''}`)}>
131+
<Button variant="contained" color="success" startIcon={<EditIcon />} onClick={() => {
132+
const params = new URLSearchParams();
133+
if (from) params.set('from', from);
134+
if (epicId) params.set('epicId', epicId);
135+
const qs = params.toString();
136+
navigate(`/${projectId}/tasks/${taskId}/edit${qs ? `?${qs}` : ''}`);
137+
}}>
133138
Edit
134139
</Button>
135140
<Button color="error" startIcon={<DeleteIcon />} onClick={() => setDeleteConfirm(true)}>
@@ -199,24 +204,41 @@ export default function TaskDetailPage() {
199204
<Typography variant="body2" sx={{ fontFamily: 'monospace' }}>v{task.version}</Typography>
200205
</FieldRow>
201206
<FieldRow label="Status">
202-
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
207+
{canWrite ? (
208+
<Select
209+
size="small"
210+
value={task.status}
211+
onChange={async (e) => {
212+
const s = e.target.value as TaskStatus;
213+
setTask(prev => prev ? { ...prev, status: s } : prev);
214+
await moveTask(projectId!, taskId!, s);
215+
load();
216+
}}
217+
variant="standard"
218+
disableUnderline
219+
sx={{
220+
bgcolor: alpha(COLUMNS.find(c => c.status === task.status)?.color ?? '#616161', 0.12),
221+
color: COLUMNS.find(c => c.status === task.status)?.color ?? '#616161',
222+
fontWeight: 600, fontSize: '0.75rem', borderRadius: '999px',
223+
border: `1px solid ${alpha(COLUMNS.find(c => c.status === task.status)?.color ?? '#616161', 0.3)}`,
224+
height: 26, minWidth: 70,
225+
'& .MuiSelect-select': { py: '2px', px: 1.2, display: 'flex', alignItems: 'center' },
226+
'& .MuiSelect-icon': { fontSize: '1rem', color: COLUMNS.find(c => c.status === task.status)?.color ?? '#616161', right: 4 },
227+
'&:before, &:after': { display: 'none' },
228+
}}
229+
>
230+
{COLUMNS.map(c => (
231+
<MenuItem key={c.status} value={c.status}>
232+
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
233+
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: c.color }} />
234+
{c.label}
235+
</Box>
236+
</MenuItem>
237+
))}
238+
</Select>
239+
) : (
203240
<StatusBadge label={statusLabel(task.status)} color={STATUS_BADGE_COLOR[task.status]} />
204-
{canWrite && (
205-
<FormControl size="small" sx={{ minWidth: 140 }}>
206-
<InputLabel>Move to</InputLabel>
207-
<Select value="" label="Move to" onChange={e => handleMove(e.target.value as TaskStatus)}>
208-
{COLUMNS.filter(c => c.status !== task.status).map(c => (
209-
<MenuItem key={c.status} value={c.status}>
210-
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
211-
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: c.color }} />
212-
{c.label}
213-
</Box>
214-
</MenuItem>
215-
))}
216-
</Select>
217-
</FormControl>
218-
)}
219-
</Box>
241+
)}
220242
</FieldRow>
221243
<FieldRow label="Priority">
222244
{canWrite ? (
@@ -295,7 +317,7 @@ export default function TaskDetailPage() {
295317
</Box>
296318
);
297319
}}
298-
sx={{ fontSize: '0.85rem', minWidth: 160 }}
320+
sx={{ fontSize: '0.85rem', width: '100%' }}
299321
>
300322
<MenuItem value="">No epic</MenuItem>
301323
{selectableEpics.map(e => (
@@ -320,9 +342,33 @@ export default function TaskDetailPage() {
320342
<Typography variant="body2">{task.estimate}h</Typography>
321343
</FieldRow>
322344
)}
323-
{task.assignee && (
345+
{(canWrite || task.assignee) && (
324346
<FieldRow label="Assignee">
325-
<Typography variant="body2">{team.find(m => m.id === task.assignee)?.name ?? task.assignee}</Typography>
347+
{canWrite ? (
348+
<Select
349+
size="small"
350+
value={task.assignee ?? ''}
351+
displayEmpty
352+
onChange={async (e) => {
353+
const assignee = e.target.value as string || undefined;
354+
setTask(prev => prev ? { ...prev, assignee: assignee ?? null } : prev);
355+
await updateTask(projectId!, taskId!, { assignee: assignee ?? null });
356+
load();
357+
}}
358+
renderValue={(v) => {
359+
if (!v) return <Typography variant="body2" color="text.secondary">Unassigned</Typography>;
360+
return <Typography variant="body2">{team.find(m => m.id === v)?.name ?? v}</Typography>;
361+
}}
362+
sx={{ fontSize: '0.85rem', width: '100%' }}
363+
>
364+
<MenuItem value="">Unassigned</MenuItem>
365+
{team.map(m => (
366+
<MenuItem key={m.id} value={m.id}>{m.name}</MenuItem>
367+
))}
368+
</Select>
369+
) : (
370+
<Typography variant="body2">{team.find(m => m.id === task.assignee)?.name ?? task.assignee}</Typography>
371+
)}
326372
</FieldRow>
327373
)}
328374
{task.completedAt != null && (

0 commit comments

Comments
 (0)