Skip to content

Commit 858d2e0

Browse files
feat: add /answer/{id} to load existing answers
1 parent 0fe74ba commit 858d2e0

5 files changed

Lines changed: 133 additions & 26 deletions

File tree

.claude/settings.local.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
{
22
"permissions": {
33
"allow": [
4-
"Bash(dotnet build:*)"
4+
"Bash(dotnet build:*)",
5+
"Bash(npm run build:*)"
56
],
67
"deny": [],
78
"ask": []

docs/changelog/05-changelog.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ Output files follow the naming convention established by `FileOutputWriter`:
7474
- Markdown: `consensus-{runId}.md`
7575

7676
### Error Handling
77-
- Job not found: 404 with message "Run ID '{runId}' not found"
77+
- Job not found: 404 with message "ID '{runId}' not found"
7878
- File not found: 404 with message indicating output not found and job may still be running or failed
7979
- All file operations wrapped in try-catch with proper logging
8080

src/Consensus.Api/Controllers/ConsensusController.cs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -64,14 +64,14 @@ public async Task<ActionResult<string>> GetHtml(string runId)
6464
var jobStatus = await _jobScheduler.GetJobStatusAsync(runId);
6565
if (jobStatus == null)
6666
{
67-
return NotFound(new { message = $"Run ID '{runId}' not found" });
67+
return NotFound(new { message = $"ID '{runId}' not found" });
6868
}
6969

7070
// Read HTML output file
7171
var html = await _outputFileService.ReadHtmlAsync(runId);
7272
if (html == null)
7373
{
74-
return NotFound(new { message = $"HTML output not found for run ID '{runId}'. The job may still be running or may have failed." });
74+
return NotFound(new { message = $"HTML output not found for ID '{runId}'. The job may still be running or may have failed." });
7575
}
7676

7777
return Content(html, "text/html");
@@ -102,7 +102,7 @@ public async Task<ActionResult<JobStatusModel>> StartJob([FromBody] PromptReques
102102

103103
if (!scheduled)
104104
{
105-
return Conflict(new { message = $"Job with run ID '{runId}' already exists" });
105+
return Conflict(new { message = $"Job with ID '{runId}' already exists" });
106106
}
107107

108108
var jobStatus = await _jobScheduler.GetJobStatusAsync(runId);
@@ -126,14 +126,14 @@ public async Task<ActionResult<string>> GetMarkdown(string runId)
126126
var jobStatus = await _jobScheduler.GetJobStatusAsync(runId);
127127
if (jobStatus == null)
128128
{
129-
return NotFound(new { message = $"Run ID '{runId}' not found" });
129+
return NotFound(new { message = $"ID '{runId}' not found" });
130130
}
131131

132132
// Read markdown output file
133133
var markdown = await _outputFileService.ReadMarkdownAsync(runId);
134134
if (markdown == null)
135135
{
136-
return NotFound(new { message = $"Markdown output not found for run ID '{runId}'. The job may still be running or may have failed." });
136+
return NotFound(new { message = $"Markdown output not found for ID '{runId}'. The job may still be running or may have failed." });
137137
}
138138

139139
return Content(markdown, "text/markdown");
@@ -154,7 +154,7 @@ public async Task<ActionResult<JobStatusModel>> GetJobStatus(string runId)
154154
var jobStatus = await _jobScheduler.GetJobStatusAsync(runId);
155155
if (jobStatus == null)
156156
{
157-
return NotFound(new { message = $"Run ID '{runId}' not found" });
157+
return NotFound(new { message = $"ID '{runId}' not found" });
158158
}
159159

160160
return Ok(jobStatus);

src/Consensus.Web/src/App.tsx

Lines changed: 86 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,86 @@ import { Header } from './components/Header';
66
import { consensusApi } from './services/api';
77
import type { JobStatusModel, LogEntryModel } from './types/api';
88

9+
// Helper function to validate UUID format
10+
const isValidUuid = (str: string): boolean => {
11+
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
12+
return uuidRegex.test(str);
13+
};
14+
15+
// Helper function to get runId from URL path (/answer/{runId})
16+
const getRunIdFromUrl = (): string | null => {
17+
const pathMatch = window.location.pathname.match(/\/answer\/([0-9a-f-]+)$/i);
18+
return pathMatch && isValidUuid(pathMatch[1]) ? pathMatch[1] : null;
19+
};
20+
921
function App() {
1022
const [jobStatus, setJobStatus] = useState<JobStatusModel | null>(null);
1123
const [logs, setLogs] = useState<LogEntryModel[]>([]);
1224
const [html, setHtml] = useState<string>('');
1325
const [error, setError] = useState<string>('');
1426
const [isSubmitting, setIsSubmitting] = useState(false);
1527
const [currentPrompt, setCurrentPrompt] = useState<string>('');
28+
const [isLoadingFromUrl, setIsLoadingFromUrl] = useState(false);
29+
30+
// Load response from URL path on mount
31+
useEffect(() => {
32+
const runIdFromUrl = getRunIdFromUrl();
33+
if (!runIdFromUrl) {
34+
return;
35+
}
36+
37+
const loadFromUrl = async () => {
38+
setIsLoadingFromUrl(true);
39+
setError('');
40+
41+
try {
42+
// Fetch job status
43+
const status = await consensusApi.getJobStatus(runIdFromUrl);
44+
setJobStatus(status);
45+
46+
// Fetch logs
47+
const jobLogs = await consensusApi.getLogs(runIdFromUrl);
48+
setLogs(jobLogs);
49+
50+
// If job is finished, fetch HTML
51+
if (status.status === 2) {
52+
const htmlResult = await consensusApi.getHtml(runIdFromUrl);
53+
setHtml(htmlResult);
54+
}
55+
} catch (err) {
56+
const errorMessage = err instanceof Error ? err.message : 'Failed to load response';
57+
setError(errorMessage);
58+
window.history.replaceState({}, '', '/'); // Update URL to home without adding history entry
59+
} finally {
60+
setIsLoadingFromUrl(false);
61+
}
62+
};
63+
64+
loadFromUrl();
65+
}, []); // Run only on mount
66+
67+
// Handle browser back/forward navigation
68+
useEffect(() => {
69+
const handlePopState = () => {
70+
const runIdFromUrl = getRunIdFromUrl();
71+
72+
if (!runIdFromUrl) {
73+
// User navigated back to home, reset the app
74+
setJobStatus(null);
75+
setLogs([]);
76+
setHtml('');
77+
setError('');
78+
setIsSubmitting(false);
79+
setCurrentPrompt('');
80+
} else if (!jobStatus || jobStatus.runId !== runIdFromUrl) {
81+
// User navigated to a different runId, reload
82+
window.location.reload();
83+
}
84+
};
85+
86+
window.addEventListener('popstate', handlePopState);
87+
return () => window.removeEventListener('popstate', handlePopState);
88+
}, [jobStatus]);
1689

1790
// Poll for job status
1891
useEffect(() => {
@@ -25,10 +98,11 @@ function App() {
2598
const status = await consensusApi.getJobStatus(jobStatus.runId);
2699
setJobStatus(status);
27100

28-
// If job is finished, fetch the HTML result
101+
// If job is finished, fetch the HTML result and update URL
29102
if (status.status === 2) { // Finished
30103
const htmlResult = await consensusApi.getHtml(status.runId);
31104
setHtml(htmlResult);
105+
window.history.pushState({}, '', `/answer/${status.runId}`); // Update URL with runId
32106
}
33107
} catch (err) {
34108
console.error('Error polling job status:', err);
@@ -67,6 +141,7 @@ function App() {
67141
try {
68142
const status = await consensusApi.startJob(prompt);
69143
setJobStatus(status);
144+
window.history.pushState({}, '', `/answer/${status.runId}`); // Update URL with new runId
70145

71146
// Immediately fetch logs after starting the job
72147
const initialLogs = await consensusApi.getLogs(status.runId);
@@ -85,6 +160,7 @@ function App() {
85160
setError('');
86161
setIsSubmitting(false);
87162
setCurrentPrompt('');
163+
window.history.pushState({}, '', '/'); // Navigate back to home
88164
};
89165

90166
const isJobRunning = !!(jobStatus && jobStatus.status !== 2); // Not Finished
@@ -111,11 +187,11 @@ function App() {
111187

112188
{/* Error Alert */}
113189
{error && (
114-
<div className="p-4 bg-red-50 border border-red-200 rounded-lg flex items-center justify-between">
115-
<span className="text-red-800">{error}</span>
116-
<button
190+
<div className="w-full max-w-[700px] mx-auto p-4 bg-gray-100 border border-gray-300 rounded-lg flex items-center justify-between">
191+
<span className="text-gray-700">{error}</span>
192+
<button
117193
onClick={() => setError('')}
118-
className="text-red-600 hover:text-red-800 font-bold cursor-pointer font-sans"
194+
className="text-gray-500 hover:text-gray-700 font-bold cursor-pointer font-sans"
119195
>
120196
×
121197
</button>
@@ -142,9 +218,12 @@ function App() {
142218
)}
143219

144220
{/* Loading State */}
145-
{isSubmitting && (
146-
<div className="flex justify-center py-8">
221+
{(isSubmitting || isLoadingFromUrl) && (
222+
<div className="flex flex-col items-center justify-center py-8 gap-3">
147223
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
224+
{isLoadingFromUrl && (
225+
<p className="text-sm text-gray-600">Loading response...</p>
226+
)}
148227
</div>
149228
)}
150229

src/Consensus.Web/src/services/api.ts

Lines changed: 38 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,28 @@
11
import type { PromptRequest, JobStatusModel, LogEntryModel } from '../types/api';
22

33
// In production, use same origin (served from API). In development, use env variable or default.
4-
const API_BASE_URL = import.meta.env.PROD
5-
? ''
4+
const API_BASE_URL = import.meta.env.PROD
5+
? ''
66
: (import.meta.env.VITE_API_BASE_URL || 'http://localhost:5000');
77

8+
// Helper function to extract error message from response
9+
async function getErrorMessage(response: Response, defaultMessage: string): Promise<string> {
10+
const contentType = response.headers.get('content-type');
11+
12+
try {
13+
if (contentType && contentType.includes('application/json')) {
14+
const error = await response.json();
15+
return error.message || error.title || defaultMessage;
16+
} else {
17+
const text = await response.text();
18+
return text || defaultMessage;
19+
}
20+
} catch {
21+
// If parsing fails, use default message
22+
return defaultMessage;
23+
}
24+
}
25+
826
class ConsensusApiService {
927
private baseUrl: string;
1028

@@ -17,7 +35,7 @@ class ConsensusApiService {
1735
*/
1836
async startJob(prompt: string): Promise<JobStatusModel> {
1937
const request: PromptRequest = { prompt };
20-
38+
2139
const response = await fetch(`${this.baseUrl}/api/consensus/start`, {
2240
method: 'POST',
2341
headers: {
@@ -27,8 +45,8 @@ class ConsensusApiService {
2745
});
2846

2947
if (!response.ok) {
30-
const error = await response.json();
31-
throw new Error(error.message || 'Failed to start job');
48+
const message = await getErrorMessage(response, 'Failed to start job');
49+
throw new Error(message);
3250
}
3351

3452
return response.json();
@@ -41,8 +59,11 @@ class ConsensusApiService {
4159
const response = await fetch(`${this.baseUrl}/api/consensus/${runId}/status`);
4260

4361
if (!response.ok) {
44-
const error = await response.json();
45-
throw new Error(error.message || 'Failed to get job status');
62+
const message = await getErrorMessage(
63+
response,
64+
response.status === 404 ? 'Response not found' : 'Failed to get job status'
65+
);
66+
throw new Error(message);
4667
}
4768

4869
return response.json();
@@ -55,8 +76,11 @@ class ConsensusApiService {
5576
const response = await fetch(`${this.baseUrl}/api/consensus/${runId}/logs`);
5677

5778
if (!response.ok) {
58-
const error = await response.json();
59-
throw new Error(error.message || 'Failed to get logs');
79+
const message = await getErrorMessage(
80+
response,
81+
response.status === 404 ? 'Response not found' : 'Failed to get logs'
82+
);
83+
throw new Error(message);
6084
}
6185

6286
return response.json();
@@ -69,8 +93,11 @@ class ConsensusApiService {
6993
const response = await fetch(`${this.baseUrl}/api/consensus/${runId}/html`);
7094

7195
if (!response.ok) {
72-
const error = await response.json();
73-
throw new Error(error.message || 'Failed to get HTML');
96+
const message = await getErrorMessage(
97+
response,
98+
response.status === 404 ? 'Response not found' : 'Failed to get HTML'
99+
);
100+
throw new Error(message);
74101
}
75102

76103
return response.text();

0 commit comments

Comments
 (0)