Skip to content

Commit 2d80d7d

Browse files
authored
Merge pull request #36 from BjornMelin/refactor/client-error-parsing
refactor(ui): DRY JSON error parsing + accessible confirms
2 parents 2379a72 + 91faeec commit 2d80d7d

12 files changed

Lines changed: 390 additions & 274 deletions

File tree

.github/actions/ci-setup/action.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@ inputs:
88
default: "24.x"
99

1010
bun-version:
11-
description: "Bun version to install (default: 1.3.7)"
11+
description: "Bun version to install (default: 1.3.9)"
1212
required: false
13-
default: "1.3.7"
13+
default: "1.3.9"
1414

1515
bun-download-url:
1616
description: "Optional Bun download URL override (avoids GitHub API tag fetch). If omitted, defaults to the Bun baseline Linux x64 release asset for the selected bun-version."
@@ -21,7 +21,7 @@ runs:
2121
using: composite
2222
steps:
2323
- name: Setup Node.js
24-
uses: actions/setup-node@v4
24+
uses: actions/setup-node@v6.2.0
2525
with:
2626
node-version: ${{ inputs.node-version }}
2727

.github/workflows/ci.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ jobs:
7272

7373
- name: Upload blob report
7474
if: ${{ !cancelled() }}
75-
uses: actions/upload-artifact@v4
75+
uses: actions/upload-artifact@v6.0.0
7676
with:
7777
name: blob-report-${{ matrix.shardIndex }}
7878
path: .vitest-reports/blob/${{ matrix.shardIndex }}.json
@@ -93,7 +93,7 @@ jobs:
9393
uses: ./.github/actions/ci-setup
9494

9595
- name: Download blob reports
96-
uses: actions/download-artifact@v4
96+
uses: actions/download-artifact@v7.0.0
9797
with:
9898
pattern: blob-report-*
9999
path: .vitest-reports/blob
@@ -104,7 +104,7 @@ jobs:
104104

105105
- name: Upload JUnit report
106106
if: ${{ !cancelled() }}
107-
uses: actions/upload-artifact@v4
107+
uses: actions/upload-artifact@v6.0.0
108108
with:
109109
name: vitest-junit
110110
path: .vitest-reports/junit.xml

.github/workflows/codeql.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,16 +27,16 @@ jobs:
2727
uses: actions/checkout@v6.0.2
2828

2929
- name: Initialize CodeQL
30-
uses: github/codeql-action/init@v4.32.0
30+
uses: github/codeql-action/init@v4.32.2
3131
with:
3232
languages: ${{ matrix.language }}
3333
queries: security-extended
3434
config-file: ./.github/codeql/codeql-config.yml
3535

3636
- name: Autobuild
37-
uses: github/codeql-action/autobuild@v4.32.0
37+
uses: github/codeql-action/autobuild@v4.32.2
3838

3939
- name: Perform CodeQL Analysis
40-
uses: github/codeql-action/analyze@v4.32.0
40+
uses: github/codeql-action/analyze@v4.32.2
4141
with:
4242
category: "/language:${{ matrix.language }}"

.github/workflows/scorecard.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,6 @@ jobs:
4040
retention-days: 5
4141

4242
- name: Upload to code-scanning
43-
uses: github/codeql-action/upload-sarif@v4.32.0
43+
uses: github/codeql-action/upload-sarif@v4.32.2
4444
with:
4545
sarif_file: results.sarif

src/app/(app)/projects/[projectId]/approvals/approvals-client.tsx

Lines changed: 19 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
import { useRouter } from "next/navigation";
44
import { startTransition, useId, useState } from "react";
5-
import { z } from "zod/mini";
65
import { Button } from "@/components/ui/button";
76
import {
87
Dialog,
@@ -18,19 +17,7 @@ import {
1817
EmptyHeader,
1918
EmptyTitle,
2019
} from "@/components/ui/empty";
21-
22-
const approvalsResponseSchema = z.looseObject({
23-
approvals: z.optional(z.array(z.unknown())),
24-
});
25-
26-
const errorResponseSchema = z.looseObject({
27-
error: z.optional(
28-
z.looseObject({
29-
code: z.optional(z.string()),
30-
message: z.optional(z.string()),
31-
}),
32-
),
33-
});
20+
import { tryReadJsonErrorMessage } from "@/lib/core/errors";
3421

3522
type ApprovalSummary = Readonly<{
3623
id: string;
@@ -110,36 +97,34 @@ export function ApprovalsClient(
11097
}
11198

11299
if (!res.ok) {
113-
let message = `Failed to refresh approvals (${res.status}).`;
114-
try {
115-
const jsonUnknown: unknown = await res.json();
116-
const parsed = errorResponseSchema.safeParse(jsonUnknown);
117-
const fromServer = parsed.success ? parsed.data.error?.message : null;
118-
if (fromServer) message = fromServer;
119-
} catch {
120-
// Ignore.
121-
}
100+
const fromServer = await tryReadJsonErrorMessage(res);
101+
const message =
102+
fromServer ?? `Failed to refresh approvals (${res.status}).`;
122103
setIsRefreshing(false);
123104
setError(message);
124105
return;
125106
}
126107

108+
let jsonUnknown: unknown;
127109
try {
128-
const jsonUnknown: unknown = await res.json();
129-
const parsed = approvalsResponseSchema.safeParse(jsonUnknown);
130-
const list = parsed.success ? parsed.data.approvals : null;
131-
const next =
132-
list && Array.isArray(list)
133-
? list.filter(isApprovalSummary)
134-
: props.initialApprovals;
135-
setApprovals(next);
110+
jsonUnknown = await res.json();
136111
} catch (err) {
137112
setError(
138113
err instanceof Error ? err.message : "Failed to parse approvals.",
139114
);
140-
} finally {
141115
setIsRefreshing(false);
116+
return;
142117
}
118+
119+
const approvalsValue =
120+
jsonUnknown && typeof jsonUnknown === "object"
121+
? (jsonUnknown as { approvals?: unknown }).approvals
122+
: undefined;
123+
const next = Array.isArray(approvalsValue)
124+
? approvalsValue.filter(isApprovalSummary)
125+
: props.initialApprovals;
126+
setApprovals(next);
127+
setIsRefreshing(false);
143128
};
144129

145130
const approve = async (approvalId: string): Promise<boolean> => {
@@ -160,15 +145,8 @@ export function ApprovalsClient(
160145
}
161146

162147
if (!res.ok) {
163-
let message = `Failed to approve (${res.status}).`;
164-
try {
165-
const jsonUnknown: unknown = await res.json();
166-
const parsed = errorResponseSchema.safeParse(jsonUnknown);
167-
const fromServer = parsed.success ? parsed.data.error?.message : null;
168-
if (fromServer) message = fromServer;
169-
} catch {
170-
// Ignore.
171-
}
148+
const fromServer = await tryReadJsonErrorMessage(res);
149+
const message = fromServer ?? `Failed to approve (${res.status}).`;
172150
setApprovingId(null);
173151
setError(message);
174152
return false;

src/app/(app)/projects/[projectId]/code-mode/code-mode-client.tsx

Lines changed: 11 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,7 @@
11
"use client";
22

33
import { Loader2Icon } from "lucide-react";
4-
import {
5-
startTransition,
6-
useEffect,
7-
useId,
8-
useMemo,
9-
useRef,
10-
useState,
11-
} from "react";
4+
import { startTransition, useEffect, useId, useRef, useState } from "react";
125
import { z } from "zod/mini";
136

147
import { Terminal } from "@/components/ai-elements/terminal";
@@ -21,6 +14,7 @@ import {
2114
SelectTrigger,
2215
SelectValue,
2316
} from "@/components/ui/select";
17+
import { tryReadJsonErrorMessage } from "@/lib/core/errors";
2418

2519
type StreamStatus = "idle" | "streaming" | "done" | "error";
2620
const STREAM_EVENT_FLUSH_MS = 16;
@@ -30,10 +24,6 @@ const startResponseSchema = z.looseObject({
3024
workflowRunId: z.optional(z.string()),
3125
});
3226

33-
const errorResponseSchema = z.looseObject({
34-
error: z.optional(z.looseObject({ message: z.optional(z.string()) })),
35-
});
36-
3727
const uiMessageChunkSchema = z.looseObject({
3828
data: z.optional(z.unknown()),
3929
type: z.string(),
@@ -139,11 +129,6 @@ export function CodeModeClient(props: Readonly<{ projectId: string }>) {
139129
[],
140130
);
141131

142-
const streamStorageKey = useMemo(
143-
() => (runId ? `workflow:code-mode:v1:${runId}:startIndex` : null),
144-
[runId],
145-
);
146-
147132
const start = async () => {
148133
abortRef.current?.abort();
149134
abortRef.current = new AbortController();
@@ -175,15 +160,9 @@ export function CodeModeClient(props: Readonly<{ projectId: string }>) {
175160
}
176161

177162
if (!res.ok) {
178-
let message = `Failed to start Code Mode (${res.status}).`;
179-
try {
180-
const jsonUnknown: unknown = await res.json();
181-
const parsed = errorResponseSchema.safeParse(jsonUnknown);
182-
const fromServer = parsed.success ? parsed.data.error?.message : null;
183-
if (fromServer) message = fromServer;
184-
} catch {
185-
// Ignore.
186-
}
163+
const fromServer = await tryReadJsonErrorMessage(res);
164+
const message =
165+
fromServer ?? `Failed to start Code Mode (${res.status}).`;
187166
setStatus("error");
188167
setError(message);
189168
return;
@@ -226,14 +205,14 @@ export function CodeModeClient(props: Readonly<{ projectId: string }>) {
226205
};
227206

228207
useEffect(() => {
229-
if (!runId || !streamStorageKey) return;
230-
void reconnectSeed;
208+
if (!runId) return;
209+
void reconnectSeed; // Reference to trigger effect re-run on reconnect requests
231210

232211
const abort = abortRef.current;
233212
if (!abort) return;
234213

235214
const currentRunId = runId;
236-
const storageKey = streamStorageKey;
215+
const storageKey = `workflow:code-mode:v1:${currentRunId}:startIndex`;
237216

238217
let startIndex = readStartIndex(storageKey);
239218
const autoReconnectDelaysMs = [250, 750, 1500] as const;
@@ -280,15 +259,8 @@ export function CodeModeClient(props: Readonly<{ projectId: string }>) {
280259
}
281260

282261
if (!res.ok) {
283-
let message = `Failed to open stream (${res.status}).`;
284-
try {
285-
const jsonUnknown: unknown = await res.json();
286-
const parsed = errorResponseSchema.safeParse(jsonUnknown);
287-
const fromServer = parsed.success ? parsed.data.error?.message : null;
288-
if (fromServer) message = fromServer;
289-
} catch {
290-
// Ignore.
291-
}
262+
const fromServer = await tryReadJsonErrorMessage(res);
263+
const message = fromServer ?? `Failed to open stream (${res.status}).`;
292264
setError(message);
293265
setStatus("error");
294266
return "error";
@@ -471,7 +443,7 @@ export function CodeModeClient(props: Readonly<{ projectId: string }>) {
471443
setStatus("error");
472444
setError(err instanceof Error ? err.message : "Stream disconnected.");
473445
});
474-
}, [reconnectSeed, runId, streamStorageKey]);
446+
}, [reconnectSeed, runId]);
475447

476448
const cancel = async () => {
477449
if (!runId) return;

src/app/(app)/projects/[projectId]/repos/repo-connect-client.tsx

Lines changed: 3 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -3,23 +3,10 @@
33
import { Loader2Icon } from "lucide-react";
44
import { useRouter } from "next/navigation";
55
import { startTransition, useId, useState } from "react";
6-
import { z } from "zod/mini";
76

87
import { Button } from "@/components/ui/button";
98
import { Input } from "@/components/ui/input";
10-
11-
const connectResponseSchema = z.looseObject({
12-
repo: z.optional(z.looseObject({ id: z.optional(z.string()) })),
13-
});
14-
15-
const errorResponseSchema = z.looseObject({
16-
error: z.optional(
17-
z.looseObject({
18-
code: z.optional(z.string()),
19-
message: z.optional(z.string()),
20-
}),
21-
),
22-
});
9+
import { tryReadJsonErrorMessage } from "@/lib/core/errors";
2310

2411
type RepoSummary = Readonly<{
2512
id: string;
@@ -87,32 +74,13 @@ export function RepoConnectClient(
8774
}
8875

8976
if (!res.ok) {
90-
let message = `Failed to connect repo (${res.status}).`;
91-
try {
92-
const jsonUnknown: unknown = await res.json();
93-
const parsed = errorResponseSchema.safeParse(jsonUnknown);
94-
const fromServer = parsed.success ? parsed.data.error?.message : null;
95-
if (fromServer) message = fromServer;
96-
} catch {
97-
// Ignore.
98-
}
77+
const fromServer = await tryReadJsonErrorMessage(res);
78+
const message = fromServer ?? `Failed to connect repo (${res.status}).`;
9979
setIsPending(false);
10080
setError(message);
10181
return;
10282
}
10383

104-
try {
105-
const jsonUnknown: unknown = await res.json();
106-
const parsed = connectResponseSchema.safeParse(jsonUnknown);
107-
if (!parsed.success) {
108-
throw new Error("Unexpected response from server.");
109-
}
110-
} catch (err) {
111-
setIsPending(false);
112-
setError(err instanceof Error ? err.message : "Failed to connect repo.");
113-
return;
114-
}
115-
11684
setIsPending(false);
11785
setOwner("");
11886
setName("");

0 commit comments

Comments
 (0)