@echothink-ui/developer 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +5 -0
  2. package/dist/components/APIExplorer.d.ts +2 -0
  3. package/dist/components/BranchSelector.d.ts +2 -0
  4. package/dist/components/CodeBlock.d.ts +2 -0
  5. package/dist/components/CodeEditor.d.ts +2 -0
  6. package/dist/components/CommitList.d.ts +2 -0
  7. package/dist/components/DiffTable.d.ts +2 -0
  8. package/dist/components/DiffViewer.d.ts +2 -0
  9. package/dist/components/EventPayloadViewer.d.ts +2 -0
  10. package/dist/components/GitRepositoryPanel.d.ts +2 -0
  11. package/dist/components/JSONViewer.d.ts +2 -0
  12. package/dist/components/LogConsole.d.ts +2 -0
  13. package/dist/components/PullRequestPanel.d.ts +2 -0
  14. package/dist/components/RequestResponseViewer.d.ts +2 -0
  15. package/dist/components/SchemaViewer.d.ts +2 -0
  16. package/dist/components/TerminalPanel.d.ts +2 -0
  17. package/dist/components/TraceTimeline.d.ts +2 -0
  18. package/dist/components/WebhookEventViewer.d.ts +2 -0
  19. package/dist/components/YAMLViewer.d.ts +2 -0
  20. package/dist/components/devUtils.d.ts +10 -0
  21. package/dist/components/types.d.ts +196 -0
  22. package/dist/index.cjs +2627 -0
  23. package/dist/index.cjs.map +1 -0
  24. package/dist/index.css +3651 -0
  25. package/dist/index.css.map +1 -0
  26. package/dist/index.d.ts +22 -0
  27. package/dist/index.js +2572 -0
  28. package/dist/index.js.map +1 -0
  29. package/package.json +43 -0
  30. package/src/components/APIExplorer.tsx +205 -0
  31. package/src/components/BranchSelector.tsx +54 -0
  32. package/src/components/CodeBlock.tsx +127 -0
  33. package/src/components/CodeEditor.tsx +95 -0
  34. package/src/components/CommitList.tsx +100 -0
  35. package/src/components/DiffTable.tsx +288 -0
  36. package/src/components/DiffViewer.tsx +145 -0
  37. package/src/components/EventPayloadViewer.tsx +91 -0
  38. package/src/components/GitRepositoryPanel.tsx +73 -0
  39. package/src/components/JSONViewer.tsx +189 -0
  40. package/src/components/LogConsole.tsx +160 -0
  41. package/src/components/PullRequestPanel.test.tsx +52 -0
  42. package/src/components/PullRequestPanel.tsx +215 -0
  43. package/src/components/RequestResponseViewer.test.tsx +45 -0
  44. package/src/components/RequestResponseViewer.tsx +169 -0
  45. package/src/components/SchemaViewer.tsx +157 -0
  46. package/src/components/TerminalPanel.test.tsx +33 -0
  47. package/src/components/TerminalPanel.tsx +134 -0
  48. package/src/components/TraceTimeline.test.tsx +63 -0
  49. package/src/components/TraceTimeline.tsx +207 -0
  50. package/src/components/WebhookEventViewer.test.tsx +57 -0
  51. package/src/components/WebhookEventViewer.tsx +184 -0
  52. package/src/components/YAMLViewer.tsx +207 -0
  53. package/src/components/devUtils.ts +81 -0
  54. package/src/components/types.ts +230 -0
  55. package/src/index.tsx +72 -0
  56. package/src/styles.css +4296 -0
@@ -0,0 +1,189 @@
1
+ import * as React from "react";
2
+ import type { JSONViewerProps } from "./types";
3
+
4
+ export function JSONViewer({
5
+ value,
6
+ defaultExpandedDepth = 2,
7
+ className,
8
+ ...props
9
+ }: JSONViewerProps) {
10
+ const [view, setView] = React.useState<"tree" | "raw">("tree");
11
+ const treeTabRef = React.useRef<HTMLButtonElement>(null);
12
+ const rawTabRef = React.useRef<HTMLButtonElement>(null);
13
+ const treeTabId = React.useId();
14
+ const rawTabId = React.useId();
15
+ const treePanelId = React.useId();
16
+ const rawPanelId = React.useId();
17
+ const selectView = (nextView: "tree" | "raw", shouldFocus = false) => {
18
+ setView(nextView);
19
+
20
+ if (shouldFocus) {
21
+ window.requestAnimationFrame(() => {
22
+ (nextView === "tree" ? treeTabRef : rawTabRef).current?.focus();
23
+ });
24
+ }
25
+ };
26
+ const onTabKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {
27
+ if (event.key === "ArrowLeft" || event.key === "Home") {
28
+ event.preventDefault();
29
+ selectView("tree", true);
30
+ } else if (event.key === "ArrowRight" || event.key === "End") {
31
+ event.preventDefault();
32
+ selectView("raw", true);
33
+ }
34
+ };
35
+
36
+ return (
37
+ <section
38
+ {...props}
39
+ className={`eth-dev-json-viewer ${className ?? ""}`}
40
+ data-eth-component="JSONViewer"
41
+ >
42
+ <header className="eth-dev-json-viewer__tabs" role="tablist" aria-label="JSON view mode">
43
+ <button
44
+ aria-controls={treePanelId}
45
+ aria-selected={view === "tree"}
46
+ className="eth-dev-json-viewer__tab"
47
+ id={treeTabId}
48
+ ref={treeTabRef}
49
+ role="tab"
50
+ tabIndex={view === "tree" ? 0 : -1}
51
+ type="button"
52
+ onClick={() => selectView("tree")}
53
+ onKeyDown={onTabKeyDown}
54
+ >
55
+ Tree
56
+ </button>
57
+ <button
58
+ aria-controls={rawPanelId}
59
+ aria-selected={view === "raw"}
60
+ className="eth-dev-json-viewer__tab"
61
+ id={rawTabId}
62
+ ref={rawTabRef}
63
+ role="tab"
64
+ tabIndex={view === "raw" ? 0 : -1}
65
+ type="button"
66
+ onClick={() => selectView("raw")}
67
+ onKeyDown={onTabKeyDown}
68
+ >
69
+ Raw
70
+ </button>
71
+ </header>
72
+ <div
73
+ aria-labelledby={treeTabId}
74
+ className="eth-dev-json-viewer__tree"
75
+ hidden={view !== "tree"}
76
+ id={treePanelId}
77
+ role="tabpanel"
78
+ tabIndex={0}
79
+ >
80
+ <div className="eth-dev-json-viewer__tree-body">
81
+ <JSONNode name="root" value={value} depth={0} expandedDepth={defaultExpandedDepth} />
82
+ </div>
83
+ </div>
84
+ <pre
85
+ aria-labelledby={rawTabId}
86
+ className="eth-dev-json-viewer__raw"
87
+ hidden={view !== "raw"}
88
+ id={rawPanelId}
89
+ role="tabpanel"
90
+ tabIndex={0}
91
+ >
92
+ <code>{formatRawValue(value)}</code>
93
+ </pre>
94
+ </section>
95
+ );
96
+ }
97
+
98
+ function JSONNode({
99
+ name,
100
+ value,
101
+ depth,
102
+ expandedDepth
103
+ }: {
104
+ name: string;
105
+ value: unknown;
106
+ depth: number;
107
+ expandedDepth: number;
108
+ }) {
109
+ if (value === null || typeof value !== "object") {
110
+ const type = leafType(value);
111
+
112
+ return (
113
+ <div className="eth-dev-json-viewer__leaf">
114
+ <span className="eth-dev-json-viewer__key">{name}</span>
115
+ <span className="eth-dev-json-viewer__punctuation">:</span>
116
+ <span className={`eth-dev-json-viewer__value eth-dev-json-viewer__value--${type}`}>
117
+ {formatLeafValue(value)}
118
+ </span>
119
+ </div>
120
+ );
121
+ }
122
+
123
+ const entries = Array.isArray(value)
124
+ ? value.map((item, index) => [String(index), item] as const)
125
+ : Object.entries(value as Record<string, unknown>);
126
+ const isArray = Array.isArray(value);
127
+ const collectionMeta = isArray ? `[${entries.length}]` : `{${entries.length}}`;
128
+
129
+ if (entries.length === 0) {
130
+ return (
131
+ <div className="eth-dev-json-viewer__leaf eth-dev-json-viewer__leaf--empty">
132
+ <span className="eth-dev-json-viewer__key">{name}</span>
133
+ <span className="eth-dev-json-viewer__node-meta">{isArray ? "[]" : "{}"}</span>
134
+ </div>
135
+ );
136
+ }
137
+
138
+ return (
139
+ <details open={depth < expandedDepth} className="eth-dev-json-viewer__branch">
140
+ <summary>
141
+ <span className="eth-dev-json-viewer__disclosure" aria-hidden="true" />
142
+ <span className="eth-dev-json-viewer__summary-label">{name}</span>
143
+ <span className="eth-dev-json-viewer__node-meta">{collectionMeta}</span>
144
+ </summary>
145
+ <div className="eth-dev-json-viewer__children">
146
+ {entries.map(([key, child]) => (
147
+ <JSONNode
148
+ key={key}
149
+ name={key}
150
+ value={child}
151
+ depth={depth + 1}
152
+ expandedDepth={expandedDepth}
153
+ />
154
+ ))}
155
+ </div>
156
+ </details>
157
+ );
158
+ }
159
+
160
+ function leafType(value: unknown) {
161
+ if (value === null) return "null";
162
+ return typeof value;
163
+ }
164
+
165
+ function formatRawValue(value: unknown) {
166
+ try {
167
+ const formatted = JSON.stringify(value, null, 2);
168
+
169
+ if (typeof formatted === "string") return formatted;
170
+ } catch {
171
+ // Fall back to the same scalar formatter used by the tree view.
172
+ }
173
+
174
+ return formatLeafValue(value);
175
+ }
176
+
177
+ function formatLeafValue(value: unknown) {
178
+ if (typeof value === "string") return JSON.stringify(value);
179
+ if (typeof value === "undefined") return "undefined";
180
+ if (typeof value === "symbol") return value.toString();
181
+ if (typeof value === "function") return "[Function]";
182
+ if (value === null) return "null";
183
+
184
+ try {
185
+ return JSON.stringify(value);
186
+ } catch {
187
+ return String(value);
188
+ }
189
+ }
@@ -0,0 +1,160 @@
1
+ import * as React from "react";
2
+ import { Pause, Play } from "@carbon/icons-react";
3
+ import { Badge, Button } from "@echothink-ui/core";
4
+ import type { LogConsoleProps, LogEntry } from "./types";
5
+
6
+ type LogEntryTone = "debug" | "info" | "warn" | "error" | "stdout" | "stderr";
7
+
8
+ const levelAliases: Record<string, LogEntryTone> = {
9
+ debug: "debug",
10
+ error: "error",
11
+ err: "error",
12
+ fatal: "error",
13
+ info: "info",
14
+ notice: "info",
15
+ stderr: "stderr",
16
+ stdout: "stdout",
17
+ trace: "debug",
18
+ warn: "warn",
19
+ warning: "warn"
20
+ };
21
+
22
+ function getEntryLevel(entry: LogEntry) {
23
+ return (entry.level ?? entry.stream ?? "info").toString();
24
+ }
25
+
26
+ function getEntryTone(entry: LogEntry) {
27
+ return levelAliases[getEntryLevel(entry).toLowerCase()] ?? "info";
28
+ }
29
+
30
+ function isWarning(entry: LogEntry) {
31
+ return getEntryTone(entry) === "warn";
32
+ }
33
+
34
+ function isError(entry: LogEntry) {
35
+ const tone = getEntryTone(entry);
36
+ return tone === "error" || tone === "stderr";
37
+ }
38
+
39
+ function formatLevel(entry: LogEntry) {
40
+ return getEntryLevel(entry).toUpperCase();
41
+ }
42
+
43
+ function formatMessage(entry: LogEntry) {
44
+ return entry.message ?? entry.text ?? "";
45
+ }
46
+
47
+ export function LogConsole({
48
+ entries,
49
+ onPause,
50
+ streaming = false,
51
+ filters,
52
+ className,
53
+ role = "region",
54
+ "aria-label": ariaLabel = "Streaming log console",
55
+ "aria-labelledby": ariaLabelledBy,
56
+ ...props
57
+ }: LogConsoleProps) {
58
+ const warningCount = entries.filter(isWarning).length;
59
+ const errorCount = entries.filter(isError).length;
60
+ const latestEntry = entries[entries.length - 1];
61
+ const latestTimestamp = latestEntry?.timestamp ?? "No activity";
62
+ const hasToolbar = Boolean(filters || onPause);
63
+
64
+ return (
65
+ <section
66
+ {...props}
67
+ role={role}
68
+ aria-label={ariaLabelledBy ? undefined : ariaLabel}
69
+ aria-labelledby={ariaLabelledBy}
70
+ className={`eth-dev-log-console ${streaming ? "eth-dev-log-console--streaming" : "eth-dev-log-console--paused"} ${className ?? ""}`}
71
+ data-eth-component="LogConsole"
72
+ >
73
+ <header className="eth-dev-log-console__header">
74
+ <div className="eth-dev-log-console__heading">
75
+ <p className="eth-dev-log-console__eyebrow">Developer console</p>
76
+ <h3>Streaming logs</h3>
77
+ <p>
78
+ {entries.length} {entries.length === 1 ? "entry" : "entries"} buffered from the active
79
+ stream.
80
+ </p>
81
+ </div>
82
+ <div className="eth-dev-log-console__status" aria-live="polite">
83
+ <Badge severity={streaming ? "success" : "neutral"}>
84
+ {streaming ? "Streaming" : "Paused"}
85
+ </Badge>
86
+ <span>{errorCount ? `${errorCount} errors` : "No errors"}</span>
87
+ </div>
88
+ </header>
89
+ {hasToolbar ? (
90
+ <div className="eth-dev-log-console__toolbar" role="toolbar" aria-label="Log controls">
91
+ {filters ? <div className="eth-dev-log-console__filters">{filters}</div> : null}
92
+ {onPause ? (
93
+ <Button
94
+ intent={streaming ? "secondary" : "primary"}
95
+ density="compact"
96
+ icon={streaming ? <Pause size={16} /> : <Play size={16} />}
97
+ onClick={onPause}
98
+ >
99
+ {streaming ? "Pause stream" : "Resume stream"}
100
+ </Button>
101
+ ) : null}
102
+ </div>
103
+ ) : null}
104
+ <dl className="eth-dev-log-console__summary" aria-label="Log summary">
105
+ <div>
106
+ <dt>Entries</dt>
107
+ <dd>{entries.length}</dd>
108
+ </div>
109
+ <div>
110
+ <dt>Warnings</dt>
111
+ <dd>{warningCount}</dd>
112
+ </div>
113
+ <div>
114
+ <dt>Errors</dt>
115
+ <dd>{errorCount}</dd>
116
+ </div>
117
+ <div>
118
+ <dt>Latest</dt>
119
+ <dd>{latestTimestamp}</dd>
120
+ </div>
121
+ </dl>
122
+ <div className="eth-dev-log-console__viewport">
123
+ {entries.length ? (
124
+ <div
125
+ className="eth-dev-log-console__entries"
126
+ role="log"
127
+ aria-live={streaming ? "polite" : "off"}
128
+ aria-relevant="additions text"
129
+ aria-atomic="false"
130
+ >
131
+ {entries.map((entry) => {
132
+ const tone = getEntryTone(entry);
133
+
134
+ return (
135
+ <div
136
+ key={entry.id}
137
+ className={`eth-dev-log-console__entry eth-dev-log-console__entry--${tone}`}
138
+ >
139
+ <time className="eth-dev-log-console__timestamp">
140
+ {entry.timestamp ?? "--:--:--"}
141
+ </time>
142
+ <span className="eth-dev-log-console__level">{formatLevel(entry)}</span>
143
+ <span className="eth-dev-log-console__source">
144
+ {entry.source ?? entry.stream ?? "system"}
145
+ </span>
146
+ <code className="eth-dev-log-console__message">{formatMessage(entry)}</code>
147
+ </div>
148
+ );
149
+ })}
150
+ </div>
151
+ ) : (
152
+ <div className="eth-dev-log-console__empty" role="status">
153
+ <h4>No log entries</h4>
154
+ <p>Events will appear here when the stream receives output.</p>
155
+ </div>
156
+ )}
157
+ </div>
158
+ </section>
159
+ );
160
+ }
@@ -0,0 +1,52 @@
1
+ import { fireEvent, render, screen, within } from "@testing-library/react";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { PullRequestPanel } from "./PullRequestPanel";
4
+
5
+ describe("PullRequestPanel", () => {
6
+ it("renders pull request summary, file deltas, and review actions", () => {
7
+ const approve = vi.fn();
8
+ const comment = vi.fn();
9
+
10
+ render(
11
+ <PullRequestPanel
12
+ pr={{
13
+ id: "42",
14
+ title: "Add approval gate v2",
15
+ body: "Adds a richer approval gate with evidence and policy refs.",
16
+ state: "open",
17
+ author: "JD",
18
+ reviewers: ["MK", "AL"],
19
+ commits: 6,
20
+ files: [
21
+ { path: "src/api.ts", additions: 12, deletions: 3 },
22
+ { path: "src/ui.tsx", additions: 4, deletions: 8 }
23
+ ]
24
+ }}
25
+ onApprove={approve}
26
+ onComment={comment}
27
+ />
28
+ );
29
+
30
+ const panel = screen.getByRole("region", {
31
+ name: "Pull request Add approval gate v2"
32
+ });
33
+ expect(within(panel).getByText("Open")).toBeTruthy();
34
+ expect(within(panel).getByText("#42")).toBeTruthy();
35
+ expect(within(panel).getByText("MK, AL")).toBeTruthy();
36
+
37
+ const filesTable = within(panel).getByRole("table", { name: "Changed files" });
38
+ expect(within(filesTable).getByText("src/api.ts")).toBeTruthy();
39
+ expect(within(filesTable).getByText("+12")).toBeTruthy();
40
+ expect(within(filesTable).getByText("-3")).toBeTruthy();
41
+
42
+ fireEvent.click(within(panel).getByRole("button", { name: "Approve pull request" }));
43
+ expect(approve).toHaveBeenCalledTimes(1);
44
+
45
+ fireEvent.change(within(panel).getByRole("textbox", { name: "Review comment" }), {
46
+ target: { value: "Looks good." }
47
+ });
48
+ fireEvent.click(within(panel).getByRole("button", { name: "Submit review comment" }));
49
+
50
+ expect(comment).toHaveBeenCalledWith("Looks good.");
51
+ });
52
+ });
@@ -0,0 +1,215 @@
1
+ import * as React from "react";
2
+ import { Checkmark, PullRequest } from "@carbon/icons-react";
3
+ import { Button, EmptyState, Textarea } from "@echothink-ui/core";
4
+ import { DataTable, type DataColumn } from "@echothink-ui/data";
5
+ import type { PullRequestPanelProps } from "./types";
6
+
7
+ type FileRow = { path: string; additions: number; deletions: number } & Record<string, unknown>;
8
+
9
+ function formatState(state: string) {
10
+ const normalized = state.trim();
11
+ if (!normalized) return "Unknown";
12
+ return normalized
13
+ .split(/[\s_-]+/)
14
+ .map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1).toLowerCase()}`)
15
+ .join(" ");
16
+ }
17
+
18
+ function stateClassName(state: string) {
19
+ const normalized = state.trim().toLowerCase();
20
+ if (["open", "opened", "ready"].includes(normalized)) {
21
+ return "eth-dev-pull-request-panel__state--open";
22
+ }
23
+ if (["merged", "approved"].includes(normalized)) {
24
+ return "eth-dev-pull-request-panel__state--merged";
25
+ }
26
+ if (["closed", "rejected", "blocked"].includes(normalized)) {
27
+ return "eth-dev-pull-request-panel__state--closed";
28
+ }
29
+ if (["draft", "pending"].includes(normalized)) {
30
+ return "eth-dev-pull-request-panel__state--draft";
31
+ }
32
+ return "eth-dev-pull-request-panel__state--unknown";
33
+ }
34
+
35
+ export function PullRequestPanel({
36
+ pr,
37
+ onApprove,
38
+ onComment,
39
+ className,
40
+ ...props
41
+ }: PullRequestPanelProps) {
42
+ const [comment, setComment] = React.useState("");
43
+ const titleId = React.useId().replace(/:/g, "");
44
+ const filesTitleId = React.useId().replace(/:/g, "");
45
+ const files = pr.files ?? [];
46
+ const reviewers = pr.reviewers?.length ? pr.reviewers.join(", ") : "No reviewers";
47
+ const additions = files.reduce((sum, file) => sum + file.additions, 0);
48
+ const deletions = files.reduce((sum, file) => sum + file.deletions, 0);
49
+ const formattedState = formatState(pr.state);
50
+ const ariaLabel =
51
+ props["aria-label"] ?? (props["aria-labelledby"] ? undefined : `Pull request ${pr.title}`);
52
+
53
+ const columns: DataColumn<FileRow>[] = [
54
+ {
55
+ key: "path",
56
+ header: "File",
57
+ render: (row) => (
58
+ <code className="eth-dev-pull-request-panel__file-path" title={row.path}>
59
+ {row.path}
60
+ </code>
61
+ )
62
+ },
63
+ {
64
+ key: "additions",
65
+ header: "Additions",
66
+ align: "end",
67
+ width: "7rem",
68
+ render: (row) => (
69
+ <span className="eth-dev-pull-request-panel__count eth-dev-pull-request-panel__count--added">
70
+ +{row.additions}
71
+ </span>
72
+ )
73
+ },
74
+ {
75
+ key: "deletions",
76
+ header: "Deletions",
77
+ align: "end",
78
+ width: "7rem",
79
+ render: (row) => (
80
+ <span className="eth-dev-pull-request-panel__count eth-dev-pull-request-panel__count--removed">
81
+ -{row.deletions}
82
+ </span>
83
+ )
84
+ }
85
+ ];
86
+ const submitComment = (event: React.FormEvent<HTMLFormElement>) => {
87
+ event.preventDefault();
88
+ const nextComment = comment.trim();
89
+ if (!nextComment) return;
90
+ onComment?.(nextComment);
91
+ setComment("");
92
+ };
93
+
94
+ return (
95
+ <section
96
+ {...props}
97
+ aria-label={ariaLabel}
98
+ className={`eth-dev-pull-request-panel ${className ?? ""}`}
99
+ data-eth-component="PullRequestPanel"
100
+ role="region"
101
+ >
102
+ <header className="eth-dev-pull-request-panel__header">
103
+ <div className="eth-dev-pull-request-panel__identity">
104
+ <span className="eth-dev-pull-request-panel__icon" aria-hidden="true">
105
+ <PullRequest size={20} />
106
+ </span>
107
+ <div className="eth-dev-pull-request-panel__heading">
108
+ <p>
109
+ Pull request <span>#{pr.id}</span>
110
+ </p>
111
+ <h3 id={titleId}>{pr.title}</h3>
112
+ <div className="eth-dev-pull-request-panel__meta">
113
+ <span
114
+ className={`eth-dev-pull-request-panel__state ${stateClassName(pr.state)}`}
115
+ >
116
+ {formattedState}
117
+ </span>
118
+ <span>by {pr.author}</span>
119
+ </div>
120
+ </div>
121
+ </div>
122
+ {onApprove ? (
123
+ <Button
124
+ aria-label="Approve pull request"
125
+ className="eth-dev-pull-request-panel__approve"
126
+ density="compact"
127
+ icon={<Checkmark size={16} />}
128
+ intent="success"
129
+ onClick={onApprove}
130
+ >
131
+ Approve
132
+ </Button>
133
+ ) : null}
134
+ </header>
135
+ <div className="eth-dev-pull-request-panel__body">
136
+ {pr.body ? <p className="eth-dev-pull-request-panel__description">{pr.body}</p> : null}
137
+ <dl className="eth-dev-pull-request-panel__metrics" aria-label="Pull request summary">
138
+ <div>
139
+ <dt>Reviewers</dt>
140
+ <dd title={reviewers}>{reviewers}</dd>
141
+ </div>
142
+ <div>
143
+ <dt>Commits</dt>
144
+ <dd>{pr.commits ?? 0}</dd>
145
+ </div>
146
+ <div>
147
+ <dt>Files changed</dt>
148
+ <dd>{files.length}</dd>
149
+ </div>
150
+ <div>
151
+ <dt>Line delta</dt>
152
+ <dd>
153
+ <span className="eth-dev-pull-request-panel__count eth-dev-pull-request-panel__count--added">
154
+ +{additions}
155
+ </span>
156
+ <span aria-hidden="true"> / </span>
157
+ <span className="eth-dev-pull-request-panel__count eth-dev-pull-request-panel__count--removed">
158
+ -{deletions}
159
+ </span>
160
+ </dd>
161
+ </div>
162
+ </dl>
163
+ </div>
164
+ <section className="eth-dev-pull-request-panel__files" aria-labelledby={filesTitleId}>
165
+ <div className="eth-dev-pull-request-panel__files-header">
166
+ <h4 id={filesTitleId}>Changed files</h4>
167
+ <span>{files.length} files</span>
168
+ </div>
169
+ {files.length ? (
170
+ <DataTable
171
+ aria-label="Changed files"
172
+ className="eth-dev-pull-request-panel__table"
173
+ columns={columns}
174
+ density="compact"
175
+ rowKey="path"
176
+ rows={files as FileRow[]}
177
+ />
178
+ ) : (
179
+ <EmptyState
180
+ title="No file changes"
181
+ description="Changed files will appear when this pull request has a diff."
182
+ />
183
+ )}
184
+ </section>
185
+ {onComment ? (
186
+ <form
187
+ aria-label="Review comment form"
188
+ className="eth-dev-pull-request-panel__comment"
189
+ onSubmit={submitComment}
190
+ >
191
+ <Textarea
192
+ helperText="Add a blocking question, approval note, or evidence reference."
193
+ labelText="Review comment"
194
+ onChange={(event) => setComment(event.currentTarget.value)}
195
+ placeholder="Reference tests, policy checks, or follow-up questions."
196
+ rows={4}
197
+ value={comment}
198
+ />
199
+ <div className="eth-dev-pull-request-panel__comment-footer">
200
+ <span>Comment will be attached to this pull request.</span>
201
+ <Button
202
+ aria-label="Submit review comment"
203
+ density="compact"
204
+ disabled={!comment.trim()}
205
+ intent="secondary"
206
+ type="submit"
207
+ >
208
+ Comment
209
+ </Button>
210
+ </div>
211
+ </form>
212
+ ) : null}
213
+ </section>
214
+ );
215
+ }
@@ -0,0 +1,45 @@
1
+ import { fireEvent, render, screen, within } from "@testing-library/react";
2
+ import { describe, expect, it } from "vitest";
3
+ import { RequestResponseViewer } from "./RequestResponseViewer";
4
+
5
+ describe("RequestResponseViewer", () => {
6
+ it("renders HTTP metadata and accessible request/response tabs", () => {
7
+ render(
8
+ <RequestResponseViewer
9
+ request={{
10
+ method: "POST",
11
+ url: "/v1/tasks/q3/approve",
12
+ headers: { "content-type": "application/json", authorization: "Bearer ***" },
13
+ body: { reason: "approved by Jane" }
14
+ }}
15
+ response={{
16
+ status: 200,
17
+ headers: { "content-type": "application/json" },
18
+ body: { ok: true, taskId: "q3" },
19
+ durationMs: 184
20
+ }}
21
+ />
22
+ );
23
+
24
+ const region = screen.getByRole("region", {
25
+ name: "POST /v1/tasks/q3/approve 200"
26
+ });
27
+
28
+ expect(within(region).getByText("POST")).toBeTruthy();
29
+ expect(within(region).getByText("/v1/tasks/q3/approve")).toBeTruthy();
30
+ expect(within(region).getByText("200")).toBeTruthy();
31
+ expect(within(region).getByText("184 ms")).toBeTruthy();
32
+
33
+ const tabs = within(region).getAllByRole("tab");
34
+ expect(tabs).toHaveLength(4);
35
+ expect(
36
+ within(region).getByRole("tab", { name: "Request headers" }).getAttribute("aria-selected")
37
+ ).toBe("true");
38
+
39
+ fireEvent.click(within(region).getByRole("tab", { name: "Response body" }));
40
+
41
+ expect(within(region).getByRole("tabpanel", { name: "Response body" }).textContent).toContain(
42
+ '"taskId": "q3"'
43
+ );
44
+ });
45
+ });