Skip to content

Commit 41b3502

Browse files
Implement issue #95: Web project page redesign
Tabbed layout (Sessions | Issues), session creation form with engine/mode/issue-link, IssueList with status badges and filters, issues API client. 22 new tests.
1 parent 897499d commit 41b3502

8 files changed

Lines changed: 962 additions & 77 deletions

File tree

web/src/api/issues.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { apiClient } from "./client";
2+
3+
export interface IssueRead {
4+
id: string;
5+
project_id: string;
6+
title: string;
7+
description: string | null;
8+
status: string;
9+
created_at: string;
10+
}
11+
12+
export type IssueStatus = "open" | "in_progress" | "closed";
13+
14+
export async function fetchIssues(
15+
projectId: string,
16+
status?: IssueStatus,
17+
): Promise<IssueRead[]> {
18+
const query = status ? `?status=${status}` : "";
19+
const response = await apiClient.get(
20+
`/api/projects/${projectId}/issues${query}`,
21+
);
22+
if (!response.ok) {
23+
throw new Error(`Failed to fetch issues: ${response.status}`);
24+
}
25+
return response.json() as Promise<IssueRead[]>;
26+
}
27+
28+
export async function createIssue(
29+
projectId: string,
30+
body: { title: string; description?: string },
31+
): Promise<IssueRead> {
32+
const response = await apiClient.post(
33+
`/api/projects/${projectId}/issues`,
34+
body,
35+
);
36+
if (!response.ok) {
37+
throw new Error(`Failed to create issue: ${response.status}`);
38+
}
39+
return response.json() as Promise<IssueRead>;
40+
}

web/src/api/sessions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export interface SessionRead {
1515

1616
export async function createSession(
1717
projectId: string,
18-
body: { name: string; engine?: string; mode?: string },
18+
body: { name: string; engine?: string; mode?: string; issue_id?: string },
1919
): Promise<SessionRead> {
2020
const response = await apiClient.post(
2121
`/api/projects/${projectId}/sessions`,

web/src/components/IssueList.tsx

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import { useState } from "react";
2+
import type { IssueRead, IssueStatus } from "@/api/issues";
3+
4+
export interface IssueListProps {
5+
issues: IssueRead[];
6+
statusFilter: IssueStatus | null;
7+
onFilterChange: (status: IssueStatus | null) => void;
8+
onCreateIssue: (title: string, description?: string) => Promise<void>;
9+
}
10+
11+
const issueStatusColors: Record<string, string> = {
12+
open: "bg-blue-100 text-blue-800",
13+
in_progress: "bg-yellow-100 text-yellow-800",
14+
closed: "bg-green-100 text-green-800",
15+
};
16+
17+
const filters: { label: string; value: IssueStatus | null }[] = [
18+
{ label: "All", value: null },
19+
{ label: "Open", value: "open" },
20+
{ label: "In Progress", value: "in_progress" },
21+
{ label: "Closed", value: "closed" },
22+
];
23+
24+
function formatRelativeTime(dateStr: string): string {
25+
const now = Date.now();
26+
const then = new Date(dateStr).getTime();
27+
const diffMs = now - then;
28+
const diffSec = Math.floor(diffMs / 1000);
29+
if (diffSec < 60) return "just now";
30+
const diffMin = Math.floor(diffSec / 60);
31+
if (diffMin < 60) return `${diffMin}m ago`;
32+
const diffHr = Math.floor(diffMin / 60);
33+
if (diffHr < 24) return `${diffHr}h ago`;
34+
const diffDay = Math.floor(diffHr / 24);
35+
return `${diffDay}d ago`;
36+
}
37+
38+
export default function IssueList({
39+
issues,
40+
statusFilter,
41+
onFilterChange,
42+
onCreateIssue,
43+
}: IssueListProps) {
44+
const [showForm, setShowForm] = useState(false);
45+
const [title, setTitle] = useState("");
46+
const [description, setDescription] = useState("");
47+
const [creating, setCreating] = useState(false);
48+
49+
async function handleSubmit(e: React.FormEvent) {
50+
e.preventDefault();
51+
if (!title.trim()) return;
52+
setCreating(true);
53+
try {
54+
await onCreateIssue(title.trim(), description.trim() || undefined);
55+
setTitle("");
56+
setDescription("");
57+
setShowForm(false);
58+
} finally {
59+
setCreating(false);
60+
}
61+
}
62+
63+
return (
64+
<div>
65+
<div className="flex items-center justify-between mb-3">
66+
<div className="flex gap-1">
67+
{filters.map((f) => (
68+
<button
69+
key={f.label}
70+
onClick={() => onFilterChange(f.value)}
71+
className={`px-3 py-1 rounded text-sm font-medium ${
72+
statusFilter === f.value
73+
? "bg-blue-600 text-white"
74+
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
75+
}`}
76+
>
77+
{f.label}
78+
</button>
79+
))}
80+
</div>
81+
<button
82+
onClick={() => setShowForm(!showForm)}
83+
className="bg-blue-600 text-white px-3 py-1.5 rounded text-sm"
84+
>
85+
+ New Issue
86+
</button>
87+
</div>
88+
89+
{showForm && (
90+
<form
91+
onSubmit={handleSubmit}
92+
className="mb-4 p-4 border border-gray-200 rounded-lg bg-white"
93+
>
94+
<div className="mb-3">
95+
<label className="block text-sm font-medium text-gray-700 mb-1">
96+
Title
97+
</label>
98+
<input
99+
type="text"
100+
value={title}
101+
onChange={(e) => setTitle(e.target.value)}
102+
placeholder="Issue title"
103+
required
104+
className="w-full border border-gray-300 rounded px-3 py-1.5 text-sm"
105+
/>
106+
</div>
107+
<div className="mb-3">
108+
<label className="block text-sm font-medium text-gray-700 mb-1">
109+
Description (optional)
110+
</label>
111+
<textarea
112+
value={description}
113+
onChange={(e) => setDescription(e.target.value)}
114+
placeholder="Issue description"
115+
rows={3}
116+
className="w-full border border-gray-300 rounded px-3 py-1.5 text-sm"
117+
/>
118+
</div>
119+
<div className="flex gap-2">
120+
<button
121+
type="submit"
122+
disabled={creating || !title.trim()}
123+
className="bg-blue-600 text-white px-3 py-1.5 rounded text-sm disabled:opacity-50"
124+
>
125+
{creating ? "Creating..." : "Create Issue"}
126+
</button>
127+
<button
128+
type="button"
129+
onClick={() => setShowForm(false)}
130+
className="bg-gray-100 text-gray-700 px-3 py-1.5 rounded text-sm"
131+
>
132+
Cancel
133+
</button>
134+
</div>
135+
</form>
136+
)}
137+
138+
{issues.length === 0 ? (
139+
<p className="text-gray-500 text-sm">No issues found.</p>
140+
) : (
141+
<ul className="divide-y divide-gray-200 border border-gray-200 rounded-lg bg-white">
142+
{issues.map((issue) => {
143+
const colorClass =
144+
issueStatusColors[issue.status] ?? "bg-gray-100 text-gray-700";
145+
return (
146+
<li key={issue.id} className="px-4 py-3">
147+
<div className="flex items-center justify-between">
148+
<span className="font-medium text-gray-900">
149+
{issue.title}
150+
</span>
151+
<span
152+
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${colorClass}`}
153+
>
154+
{issue.status}
155+
</span>
156+
</div>
157+
<div className="mt-1 text-xs text-gray-500">
158+
{formatRelativeTime(issue.created_at)}
159+
</div>
160+
</li>
161+
);
162+
})}
163+
</ul>
164+
)}
165+
</div>
166+
);
167+
}

web/src/components/SessionList.tsx

Lines changed: 59 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import { useEffect, useState } from "react";
12
import { Link } from "react-router-dom";
23
import type { SessionRead } from "@/api/sessions";
4+
import { fetchSubAgents } from "@/api/subagents";
35

46
export interface SessionListProps {
57
sessions: SessionRead[];
@@ -16,7 +18,47 @@ const statusColors: Record<string, string> = {
1618
failed: "bg-red-100 text-red-800",
1719
};
1820

21+
function formatRelativeTime(dateStr: string): string {
22+
const now = Date.now();
23+
const then = new Date(dateStr).getTime();
24+
const diffMs = now - then;
25+
const diffSec = Math.floor(diffMs / 1000);
26+
if (diffSec < 60) return "just now";
27+
const diffMin = Math.floor(diffSec / 60);
28+
if (diffMin < 60) return `${diffMin}m ago`;
29+
const diffHr = Math.floor(diffMin / 60);
30+
if (diffHr < 24) return `${diffHr}h ago`;
31+
const diffDay = Math.floor(diffHr / 24);
32+
return `${diffDay}d ago`;
33+
}
34+
1935
export default function SessionList({ sessions }: SessionListProps) {
36+
const [subAgentCounts, setSubAgentCounts] = useState<Record<string, number>>(
37+
{},
38+
);
39+
40+
useEffect(() => {
41+
let cancelled = false;
42+
async function loadCounts() {
43+
const counts: Record<string, number> = {};
44+
await Promise.all(
45+
sessions.map(async (s) => {
46+
try {
47+
const subs = await fetchSubAgents(s.id);
48+
counts[s.id] = subs.length;
49+
} catch {
50+
counts[s.id] = 0;
51+
}
52+
}),
53+
);
54+
if (!cancelled) setSubAgentCounts(counts);
55+
}
56+
if (sessions.length > 0) loadCounts();
57+
return () => {
58+
cancelled = true;
59+
};
60+
}, [sessions]);
61+
2062
if (sessions.length === 0) {
2163
return (
2264
<p className="text-gray-500 text-sm">No sessions for this project.</p>
@@ -26,7 +68,9 @@ export default function SessionList({ sessions }: SessionListProps) {
2668
return (
2769
<ul className="divide-y divide-gray-200 border border-gray-200 rounded-lg bg-white">
2870
{sessions.map((session) => {
29-
const colorClass = statusColors[session.status] ?? "bg-gray-100 text-gray-700";
71+
const colorClass =
72+
statusColors[session.status] ?? "bg-gray-100 text-gray-700";
73+
const subCount = subAgentCounts[session.id] ?? 0;
3074
return (
3175
<li key={session.id}>
3276
<Link
@@ -37,16 +81,25 @@ export default function SessionList({ sessions }: SessionListProps) {
3781
<span className="font-medium text-gray-900">
3882
{session.name}
3983
</span>
40-
<span
41-
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${colorClass}`}
42-
>
43-
{session.status}
44-
</span>
84+
<div className="flex items-center gap-2">
85+
{subCount > 0 && (
86+
<span className="inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600">
87+
{subCount} sub-agent{subCount !== 1 ? "s" : ""}
88+
</span>
89+
)}
90+
<span
91+
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${colorClass}`}
92+
>
93+
{session.status}
94+
</span>
95+
</div>
4596
</div>
4697
<div className="mt-1 text-xs text-gray-500">
4798
<span>Mode: {session.mode}</span>
4899
<span className="mx-2">|</span>
49100
<span>Engine: {session.engine}</span>
101+
<span className="mx-2">|</span>
102+
<span>{formatRelativeTime(session.created_at)}</span>
50103
</div>
51104
</Link>
52105
</li>

0 commit comments

Comments
 (0)