feat: show source inventory and rpc evidence
This commit is contained in:
@@ -1,10 +1,143 @@
|
||||
import { useReducer, useEffect, useCallback } from "react";
|
||||
import {
|
||||
connectionReducer,
|
||||
initialState,
|
||||
type SourceRecord,
|
||||
} from "./state.js";
|
||||
import { connectToServer, callOperation } from "../connection/api.js";
|
||||
import { ConnectionHeader } from "../components/ConnectionHeader.js";
|
||||
import { SourceInventory } from "../components/SourceInventory.js";
|
||||
import { ProtocolEvidence } from "../components/ProtocolEvidence.js";
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<main>
|
||||
<h1>lda.chat Workflow Console</h1>
|
||||
<ConnectionHeader />
|
||||
</main>
|
||||
const parseSources = (
|
||||
data: unknown,
|
||||
): SourceRecord[] => {
|
||||
if (!data || typeof data !== "object") return [];
|
||||
const obj = data as Record<string, unknown>;
|
||||
if (!Array.isArray(obj.sources)) return [];
|
||||
|
||||
return obj.sources.map((entry: unknown, i: number) => {
|
||||
const s = entry as Record<string, unknown>;
|
||||
const id = typeof s.id === "string" ? s.id : `source-${i}`;
|
||||
const kind = typeof s.kind === "string" ? s.kind : "unknown";
|
||||
const enabled = s.enabled !== false;
|
||||
const description =
|
||||
typeof s.description === "string" ? s.description : null;
|
||||
const counts = (s.counts ?? {}) as Record<string, number>;
|
||||
return {
|
||||
id,
|
||||
kind,
|
||||
enabled,
|
||||
description,
|
||||
toolCount: typeof counts.tools === "number" ? counts.tools : 0,
|
||||
nodeSpecCount: typeof counts.nodeSpecs === "number" ? counts.nodeSpecs : 0,
|
||||
reducerCount: typeof counts.reducers === "number" ? counts.reducers : 0,
|
||||
promptCount: typeof counts.prompts === "number" ? counts.prompts : 0,
|
||||
resourceCount:
|
||||
typeof counts.resources === "number" ? counts.resources : 0,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export const App = () => {
|
||||
const [state, dispatch] = useReducer(connectionReducer, null, initialState);
|
||||
|
||||
const loadSources = useCallback(
|
||||
async (target: string) => {
|
||||
const result = await callOperation(
|
||||
"workflow.sources.list",
|
||||
target,
|
||||
{ limit: 50 },
|
||||
);
|
||||
if (result.ok) {
|
||||
const sources = parseSources(result.interpreted);
|
||||
dispatch({
|
||||
type: "sources_loaded",
|
||||
sources,
|
||||
evidence: {
|
||||
id: `sources-${Date.now()}`,
|
||||
operation: "workflow.sources.list",
|
||||
label: "Source inventory",
|
||||
equivalentCli: "uv run wf sources list --limit 50",
|
||||
request: result.exchange.request,
|
||||
response: result.exchange.response,
|
||||
durationMs: result.durationMs,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
dispatch({
|
||||
type: "sources_error",
|
||||
message: result.error.message,
|
||||
evidence: {
|
||||
id: `sources-${Date.now()}`,
|
||||
operation: "workflow.sources.list",
|
||||
label: "Source inventory",
|
||||
equivalentCli: "uv run wf sources list --limit 50",
|
||||
request: result.exchange.request,
|
||||
response: result.exchange.response,
|
||||
durationMs: 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (state.phase === "connected" && state.connectedTarget) {
|
||||
void loadSources(state.connectedTarget);
|
||||
}
|
||||
}, [state.phase, state.connectedTarget, loadSources]);
|
||||
|
||||
const onSubmit = (target: string) => {
|
||||
dispatch({ type: "submit", target });
|
||||
void connectToServer(target).then(
|
||||
(response) => {
|
||||
if (response.ok) {
|
||||
dispatch({ type: "success", data: response });
|
||||
dispatch({
|
||||
type: "evidence_recorded",
|
||||
record: {
|
||||
id: `health-${Date.now()}`,
|
||||
operation: "workflow.health",
|
||||
label: "Health check",
|
||||
equivalentCli: "uv run wf status",
|
||||
request: response.exchange.request,
|
||||
response: response.exchange.response,
|
||||
durationMs: response.connection.durationMs,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
dispatch({
|
||||
type: "failure",
|
||||
code: response.error.code,
|
||||
message: response.error.message,
|
||||
});
|
||||
}
|
||||
},
|
||||
(e: unknown) => {
|
||||
dispatch({
|
||||
type: "failure",
|
||||
code: "rpc_protocol_error",
|
||||
message: e instanceof Error ? e.message : "unknown error",
|
||||
});
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app-layout">
|
||||
<ConnectionHeader
|
||||
state={state}
|
||||
onSubmit={onSubmit}
|
||||
onDraftChange={(value) => dispatch({ type: "draft_changed", value })}
|
||||
/>
|
||||
<SourceInventory
|
||||
sources={state.sources}
|
||||
loading={state.sourcesLoading}
|
||||
error={state.sourceError}
|
||||
/>
|
||||
<ProtocolEvidence evidence={state.evidence} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -9,6 +9,28 @@ export type ConnectionPhase =
|
||||
| "rpc_error"
|
||||
| "malformed_response";
|
||||
|
||||
export type EvidenceRecord = {
|
||||
readonly id: string;
|
||||
readonly operation: string;
|
||||
readonly label: string;
|
||||
readonly equivalentCli: string;
|
||||
readonly request: unknown;
|
||||
readonly response: unknown;
|
||||
readonly durationMs: number;
|
||||
};
|
||||
|
||||
export type SourceRecord = {
|
||||
readonly id: string;
|
||||
readonly kind: string;
|
||||
readonly enabled: boolean;
|
||||
readonly description: string | null;
|
||||
readonly toolCount: number;
|
||||
readonly nodeSpecCount: number;
|
||||
readonly reducerCount: number;
|
||||
readonly promptCount: number;
|
||||
readonly resourceCount: number;
|
||||
};
|
||||
|
||||
export type ConnectionState = {
|
||||
readonly phase: ConnectionPhase;
|
||||
readonly draftTarget: string;
|
||||
@@ -17,6 +39,10 @@ export type ConnectionState = {
|
||||
readonly storeRoot: string | null;
|
||||
readonly durationMs: number | null;
|
||||
readonly message: string | null;
|
||||
readonly evidence: ReadonlyArray<EvidenceRecord>;
|
||||
readonly sources: ReadonlyArray<SourceRecord>;
|
||||
readonly sourcesLoading: boolean;
|
||||
readonly sourceError: string | null;
|
||||
};
|
||||
|
||||
export const STORAGE_KEY = "lda.workflowConsole.target";
|
||||
@@ -50,13 +76,29 @@ export const initialState = (): ConnectionState => ({
|
||||
storeRoot: null,
|
||||
durationMs: null,
|
||||
message: null,
|
||||
evidence: [],
|
||||
sources: [],
|
||||
sourcesLoading: false,
|
||||
sourceError: null,
|
||||
});
|
||||
|
||||
export type ConnectionAction =
|
||||
| { readonly type: "submit"; readonly target: string }
|
||||
| { readonly type: "success"; readonly data: ConnectionSuccess }
|
||||
| { readonly type: "failure"; readonly code: string; readonly message: string }
|
||||
| { readonly type: "draft_changed"; readonly value: string };
|
||||
| { readonly type: "draft_changed"; readonly value: string }
|
||||
| { readonly type: "sources_loading" }
|
||||
| {
|
||||
readonly type: "sources_loaded";
|
||||
readonly sources: ReadonlyArray<SourceRecord>;
|
||||
readonly evidence: EvidenceRecord;
|
||||
}
|
||||
| {
|
||||
readonly type: "sources_error";
|
||||
readonly message: string;
|
||||
readonly evidence?: EvidenceRecord;
|
||||
}
|
||||
| { readonly type: "evidence_recorded"; readonly record: EvidenceRecord };
|
||||
|
||||
export const connectionReducer = (
|
||||
state: ConnectionState,
|
||||
@@ -69,6 +111,9 @@ export const connectionReducer = (
|
||||
phase: "connecting",
|
||||
draftTarget: action.target,
|
||||
message: null,
|
||||
sources: [],
|
||||
sourceError: null,
|
||||
sourcesLoading: false,
|
||||
};
|
||||
|
||||
case "success": {
|
||||
@@ -84,6 +129,7 @@ export const connectionReducer = (
|
||||
storeRoot: action.data.connection.storeRoot,
|
||||
durationMs: action.data.connection.durationMs,
|
||||
message: null,
|
||||
sourcesLoading: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -101,9 +147,46 @@ export const connectionReducer = (
|
||||
...state,
|
||||
draftTarget: action.value,
|
||||
};
|
||||
|
||||
case "sources_loading":
|
||||
return {
|
||||
...state,
|
||||
sourcesLoading: true,
|
||||
sourceError: null,
|
||||
};
|
||||
|
||||
case "sources_loaded":
|
||||
return {
|
||||
...state,
|
||||
sources: action.sources,
|
||||
sourcesLoading: false,
|
||||
sourceError: null,
|
||||
evidence: appendEvidence(state.evidence, action.evidence),
|
||||
};
|
||||
|
||||
case "sources_error":
|
||||
return {
|
||||
...state,
|
||||
sourcesLoading: false,
|
||||
sourceError: action.message,
|
||||
evidence: action.evidence
|
||||
? appendEvidence(state.evidence, action.evidence)
|
||||
: state.evidence,
|
||||
};
|
||||
|
||||
case "evidence_recorded":
|
||||
return {
|
||||
...state,
|
||||
evidence: appendEvidence(state.evidence, action.record),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const appendEvidence = (
|
||||
existing: ReadonlyArray<EvidenceRecord>,
|
||||
record: EvidenceRecord,
|
||||
): ReadonlyArray<EvidenceRecord> => [...existing, record];
|
||||
|
||||
const mapCodeToPhase = (code: string): ConnectionPhase => {
|
||||
switch (code) {
|
||||
case "invalid_target":
|
||||
|
||||
@@ -2,25 +2,14 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, within, cleanup } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { ConnectionHeader } from "./ConnectionHeader.js";
|
||||
import { initialState, connectionReducer } from "../app/state.js";
|
||||
|
||||
vi.mock("../connection/api.js", () => ({
|
||||
connectToServer: vi.fn(),
|
||||
}));
|
||||
const getFirstSection = () => {
|
||||
const sections = document.querySelectorAll('section[aria-label="Connection"]');
|
||||
return sections[0] as HTMLElement;
|
||||
};
|
||||
|
||||
import { connectToServer } from "../connection/api.js";
|
||||
const mockConnect = vi.mocked(connectToServer);
|
||||
|
||||
beforeEach(() => {
|
||||
cleanup();
|
||||
mockConnect.mockReset();
|
||||
try {
|
||||
sessionStorage.clear();
|
||||
} catch {
|
||||
// jsdom may not provide sessionStorage
|
||||
}
|
||||
});
|
||||
|
||||
const successResponse = {
|
||||
const successData = {
|
||||
ok: true,
|
||||
connection: {
|
||||
status: "connected",
|
||||
@@ -33,14 +22,36 @@ const successResponse = {
|
||||
equivalentCli: "uv run wf status",
|
||||
} as const;
|
||||
|
||||
const getFirstSection = () => {
|
||||
const sections = document.querySelectorAll('section[aria-label="Connection"]');
|
||||
return sections[0] as HTMLElement;
|
||||
beforeEach(() => {
|
||||
cleanup();
|
||||
try {
|
||||
sessionStorage.clear();
|
||||
} catch {
|
||||
// jsdom may not provide sessionStorage
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const renderWithDefaults = (overrides?: {
|
||||
onSubmit?: (target: string) => void;
|
||||
onDraftChange?: (value: string) => void;
|
||||
state?: ReturnType<typeof initialState>;
|
||||
}) => {
|
||||
const state = overrides?.state ?? initialState();
|
||||
const onSubmit = overrides?.onSubmit ?? vi.fn();
|
||||
const onDraftChange = overrides?.onDraftChange ?? vi.fn();
|
||||
render(
|
||||
<ConnectionHeader state={state} onSubmit={onSubmit} onDraftChange={onDraftChange} />,
|
||||
);
|
||||
return { onSubmit, onDraftChange, state };
|
||||
};
|
||||
|
||||
describe("ConnectionHeader", () => {
|
||||
it("renders default target and Connect button", () => {
|
||||
render(<ConnectionHeader />);
|
||||
renderWithDefaults();
|
||||
expect(screen.getByLabelText("Workflow JSON-RPC URL")).toHaveValue(
|
||||
"http://127.0.0.1:8765/rpc",
|
||||
);
|
||||
@@ -50,23 +61,16 @@ describe("ConnectionHeader", () => {
|
||||
});
|
||||
|
||||
it("does not automatically call the server", () => {
|
||||
render(<ConnectionHeader />);
|
||||
expect(mockConnect).not.toHaveBeenCalled();
|
||||
const { onSubmit } = renderWithDefaults();
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows connecting state and disables button during request", async () => {
|
||||
const user = userEvent.setup();
|
||||
let resolveConnect!: (value: typeof successResponse) => void;
|
||||
mockConnect.mockReturnValue(
|
||||
new Promise((r) => {
|
||||
resolveConnect = r;
|
||||
}),
|
||||
);
|
||||
|
||||
render(<ConnectionHeader />);
|
||||
await user.click(
|
||||
within(getFirstSection()).getByRole("button", { name: "Connect" }),
|
||||
);
|
||||
it("shows connecting state and disables button during request", () => {
|
||||
const connectingState = connectionReducer(initialState(), {
|
||||
type: "submit",
|
||||
target: "http://127.0.0.1:8765/rpc",
|
||||
});
|
||||
renderWithDefaults({ state: connectingState });
|
||||
|
||||
expect(
|
||||
within(getFirstSection()).getByRole("button", { name: "Connect" }),
|
||||
@@ -74,18 +78,14 @@ describe("ConnectionHeader", () => {
|
||||
expect(
|
||||
within(getFirstSection()).getByTestId("phase-label"),
|
||||
).toHaveTextContent("Connecting\u2026");
|
||||
|
||||
resolveConnect(successResponse);
|
||||
});
|
||||
|
||||
it("shows connected state with server details", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockConnect.mockResolvedValue(successResponse);
|
||||
|
||||
render(<ConnectionHeader />);
|
||||
await user.click(
|
||||
within(getFirstSection()).getByRole("button", { name: "Connect" }),
|
||||
);
|
||||
it("shows connected state with server details", () => {
|
||||
const connectedState = connectionReducer(initialState(), {
|
||||
type: "success",
|
||||
data: successData,
|
||||
});
|
||||
renderWithDefaults({ state: connectedState });
|
||||
|
||||
expect(
|
||||
within(getFirstSection()).getByTestId("phase-label"),
|
||||
@@ -104,29 +104,38 @@ describe("ConnectionHeader", () => {
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("retains typed value on failure", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockConnect.mockResolvedValue({
|
||||
ok: false,
|
||||
error: { code: "invalid_target", message: "bad target" },
|
||||
exchange: { request: null, response: null },
|
||||
it("retains typed value on failure", () => {
|
||||
const errorState = connectionReducer(initialState(), {
|
||||
type: "failure",
|
||||
code: "invalid_target",
|
||||
message: "bad target",
|
||||
});
|
||||
|
||||
render(<ConnectionHeader />);
|
||||
const input = screen.getByLabelText("Workflow JSON-RPC URL");
|
||||
await user.clear(input);
|
||||
await user.type(input, "http://bad:9999/rpc");
|
||||
await user.click(
|
||||
within(getFirstSection()).getByRole("button", { name: "Connect" }),
|
||||
renderWithDefaults({
|
||||
state: { ...errorState, draftTarget: "http://bad:9999/rpc" },
|
||||
});
|
||||
expect(screen.getByLabelText("Workflow JSON-RPC URL")).toHaveValue(
|
||||
"http://bad:9999/rpc",
|
||||
);
|
||||
|
||||
expect(input).toHaveValue("http://bad:9999/rpc");
|
||||
expect(
|
||||
within(getFirstSection()).getByTestId("error-message"),
|
||||
).toHaveTextContent("bad target");
|
||||
});
|
||||
|
||||
it("restored target still requires explicit connect", async () => {
|
||||
it("calls onSubmit with target when form submitted", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmit = vi.fn();
|
||||
renderWithDefaults({ onSubmit });
|
||||
|
||||
await user.click(
|
||||
within(getFirstSection()).getByRole("button", { name: "Connect" }),
|
||||
);
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith(
|
||||
"http://127.0.0.1:8765/rpc",
|
||||
);
|
||||
});
|
||||
|
||||
it("restored target still requires explicit connect", () => {
|
||||
try {
|
||||
sessionStorage.setItem(
|
||||
"lda.workflowConsole.target",
|
||||
@@ -135,8 +144,7 @@ describe("ConnectionHeader", () => {
|
||||
} catch {
|
||||
// jsdom may not support sessionStorage
|
||||
}
|
||||
|
||||
render(<ConnectionHeader />);
|
||||
expect(mockConnect).not.toHaveBeenCalled();
|
||||
const { onSubmit } = renderWithDefaults();
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,35 +1,5 @@
|
||||
import { useReducer, type FormEvent } from "react";
|
||||
import {
|
||||
connectionReducer,
|
||||
initialState,
|
||||
type ConnectionAction,
|
||||
} from "../app/state.js";
|
||||
import { connectToServer } from "../connection/api.js";
|
||||
|
||||
const handleSubmit = async (
|
||||
dispatch: React.Dispatch<ConnectionAction>,
|
||||
target: string,
|
||||
) => {
|
||||
dispatch({ type: "submit", target });
|
||||
try {
|
||||
const response = await connectToServer(target);
|
||||
if (response.ok) {
|
||||
dispatch({ type: "success", data: response });
|
||||
} else {
|
||||
dispatch({
|
||||
type: "failure",
|
||||
code: response.error.code,
|
||||
message: response.error.message,
|
||||
});
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
dispatch({
|
||||
type: "failure",
|
||||
code: "rpc_protocol_error",
|
||||
message: e instanceof Error ? e.message : "unknown error",
|
||||
});
|
||||
}
|
||||
};
|
||||
import type { FormEvent } from "react";
|
||||
import type { ConnectionState } from "../app/state.js";
|
||||
|
||||
const phaseLabel = (phase: string): string => {
|
||||
switch (phase) {
|
||||
@@ -52,25 +22,27 @@ const phaseLabel = (phase: string): string => {
|
||||
}
|
||||
};
|
||||
|
||||
export const ConnectionHeader = () => {
|
||||
const [state, dispatch] = useReducer(connectionReducer, null, initialState);
|
||||
type Props = {
|
||||
readonly state: ConnectionState;
|
||||
readonly onSubmit: (target: string) => void;
|
||||
readonly onDraftChange: (value: string) => void;
|
||||
};
|
||||
|
||||
const onSubmit = (e: FormEvent) => {
|
||||
export const ConnectionHeader = ({ state, onSubmit, onDraftChange }: Props) => {
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
void handleSubmit(dispatch, state.draftTarget);
|
||||
onSubmit(state.draftTarget);
|
||||
};
|
||||
|
||||
return (
|
||||
<section aria-label="Connection">
|
||||
<form onSubmit={onSubmit}>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<label htmlFor="target-input">Workflow JSON-RPC URL</label>
|
||||
<input
|
||||
id="target-input"
|
||||
type="text"
|
||||
value={state.draftTarget}
|
||||
onChange={(e) =>
|
||||
dispatch({ type: "draft_changed", value: e.target.value })
|
||||
}
|
||||
onChange={(e) => onDraftChange(e.target.value)}
|
||||
disabled={state.phase === "connecting"}
|
||||
/>
|
||||
<button type="submit" disabled={state.phase === "connecting"}>
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, within, cleanup } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { ProtocolEvidence } from "./ProtocolEvidence.js";
|
||||
import type { EvidenceRecord } from "../app/state.js";
|
||||
|
||||
beforeEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const makeRecord = (
|
||||
overrides: Partial<EvidenceRecord> & { id: string },
|
||||
): EvidenceRecord => ({
|
||||
operation: "workflow.health",
|
||||
label: "Health check",
|
||||
equivalentCli: "uv run wf status",
|
||||
request: { method: "workflow.health" },
|
||||
response: { status: "ok" },
|
||||
durationMs: 12,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("ProtocolEvidence", () => {
|
||||
it("shows empty state when no evidence", () => {
|
||||
render(<ProtocolEvidence evidence={[]} />);
|
||||
expect(screen.getByTestId("evidence-empty")).toHaveTextContent(
|
||||
"No evidence recorded yet.",
|
||||
);
|
||||
});
|
||||
|
||||
it("lists evidence records as selectable toggles", () => {
|
||||
const evidence = [
|
||||
makeRecord({ id: "health-1", operation: "workflow.health" }),
|
||||
makeRecord({ id: "sources-1", operation: "workflow.sources.list" }),
|
||||
];
|
||||
render(<ProtocolEvidence evidence={evidence} />);
|
||||
|
||||
expect(screen.getByTestId("evidence-toggle-health-1")).toBeDefined();
|
||||
expect(screen.getByTestId("evidence-toggle-sources-1")).toBeDefined();
|
||||
});
|
||||
|
||||
it("expands to show equivalent CLI, request, and response", async () => {
|
||||
const user = userEvent.setup();
|
||||
const evidence = [
|
||||
makeRecord({
|
||||
id: "health-1",
|
||||
operation: "workflow.health",
|
||||
equivalentCli: "uv run wf status",
|
||||
request: { jsonrpc: "2.0", method: "workflow.health" },
|
||||
response: { status: "ok", storeRoot: "/tmp" },
|
||||
durationMs: 8,
|
||||
}),
|
||||
];
|
||||
render(<ProtocolEvidence evidence={evidence} />);
|
||||
|
||||
await user.click(screen.getByTestId("evidence-toggle-health-1"));
|
||||
|
||||
const detail = screen.getByTestId("evidence-detail-health-1");
|
||||
expect(detail).toBeDefined();
|
||||
|
||||
const cliSection = within(detail).getAllByRole("heading", {
|
||||
name: "Equivalent CLI",
|
||||
});
|
||||
expect(cliSection).toHaveLength(1);
|
||||
expect(detail.textContent).toContain("uv run wf status");
|
||||
|
||||
const preElements = detail.querySelectorAll("pre code");
|
||||
expect(preElements.length).toBeGreaterThanOrEqual(2);
|
||||
expect(preElements[0]?.textContent).toContain("uv run wf status");
|
||||
expect(preElements[1]?.textContent).toContain("workflow.health");
|
||||
});
|
||||
|
||||
it("shows 'No response received.' for null response", async () => {
|
||||
const user = userEvent.setup();
|
||||
const evidence = [
|
||||
makeRecord({ id: "health-1", response: null }),
|
||||
];
|
||||
render(<ProtocolEvidence evidence={evidence} />);
|
||||
|
||||
await user.click(screen.getByTestId("evidence-toggle-health-1"));
|
||||
|
||||
const detail = screen.getByTestId("evidence-detail-health-1");
|
||||
expect(detail.textContent).toContain("No response received.");
|
||||
});
|
||||
|
||||
it("renders evidence through pre/code elements, never HTML", async () => {
|
||||
const user = userEvent.setup();
|
||||
const evidence = [
|
||||
makeRecord({
|
||||
id: "health-1",
|
||||
request: { html: "<script>alert('xss')</script>" },
|
||||
response: null,
|
||||
}),
|
||||
];
|
||||
render(<ProtocolEvidence evidence={evidence} />);
|
||||
|
||||
await user.click(screen.getByTestId("evidence-toggle-health-1"));
|
||||
|
||||
const detail = screen.getByTestId("evidence-detail-health-1");
|
||||
const preCodes = detail.querySelectorAll("pre code");
|
||||
expect(preCodes.length).toBeGreaterThanOrEqual(2);
|
||||
const requestCode = preCodes[1] as Element;
|
||||
expect(requestCode.textContent).toContain("<script>");
|
||||
expect(requestCode.innerHTML).not.toContain("<script>");
|
||||
});
|
||||
|
||||
it("is keyboard operable with native button", async () => {
|
||||
const user = userEvent.setup();
|
||||
const evidence = [
|
||||
makeRecord({ id: "health-1" }),
|
||||
];
|
||||
render(<ProtocolEvidence evidence={evidence} />);
|
||||
|
||||
const toggle = screen.getByTestId("evidence-toggle-health-1");
|
||||
expect(toggle).toHaveAttribute("aria-expanded", "false");
|
||||
|
||||
await user.tab();
|
||||
await user.keyboard("{Enter}");
|
||||
|
||||
expect(toggle).toHaveAttribute("aria-expanded", "true");
|
||||
|
||||
await user.keyboard("{Enter}");
|
||||
expect(toggle).toHaveAttribute("aria-expanded", "false");
|
||||
});
|
||||
|
||||
it("collapses previous when opening another", async () => {
|
||||
const user = userEvent.setup();
|
||||
const evidence = [
|
||||
makeRecord({ id: "health-1", operation: "workflow.health" }),
|
||||
makeRecord({ id: "sources-1", operation: "workflow.sources.list" }),
|
||||
];
|
||||
render(<ProtocolEvidence evidence={evidence} />);
|
||||
|
||||
await user.click(screen.getByTestId("evidence-toggle-health-1"));
|
||||
expect(screen.getByTestId("evidence-detail-health-1")).toBeDefined();
|
||||
|
||||
await user.click(screen.getByTestId("evidence-toggle-sources-1"));
|
||||
expect(screen.getByTestId("evidence-detail-sources-1")).toBeDefined();
|
||||
expect(screen.queryByTestId("evidence-detail-health-1")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useState } from "react";
|
||||
import type { EvidenceRecord } from "../app/state.js";
|
||||
|
||||
type Props = {
|
||||
readonly evidence: ReadonlyArray<EvidenceRecord>;
|
||||
};
|
||||
|
||||
export const ProtocolEvidence = ({ evidence }: Props) => {
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
|
||||
const toggle = (id: string) => {
|
||||
setExpandedId((prev) => (prev === id ? null : id));
|
||||
};
|
||||
|
||||
return (
|
||||
<section aria-label="Protocol Evidence">
|
||||
<h2>Protocol Evidence</h2>
|
||||
{evidence.length === 0 ? (
|
||||
<p data-testid="evidence-empty">No evidence recorded yet.</p>
|
||||
) : (
|
||||
<ul data-testid="evidence-list" className="evidence-list">
|
||||
{evidence.map((record) => (
|
||||
<li key={record.id}>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={expandedId === record.id}
|
||||
data-testid={`evidence-toggle-${record.id}`}
|
||||
onClick={() => toggle(record.id)}
|
||||
className="evidence-toggle"
|
||||
>
|
||||
<span className="evidence-op">{record.operation}</span>
|
||||
<span className="evidence-label">{record.label}</span>
|
||||
<span className="evidence-duration">
|
||||
{record.durationMs}ms
|
||||
</span>
|
||||
</button>
|
||||
{expandedId === record.id && (
|
||||
<div
|
||||
data-testid={`evidence-detail-${record.id}`}
|
||||
className="evidence-detail"
|
||||
>
|
||||
<div className="evidence-field">
|
||||
<h3>Equivalent CLI</h3>
|
||||
<pre>
|
||||
<code>{record.equivalentCli}</code>
|
||||
</pre>
|
||||
</div>
|
||||
<div className="evidence-field">
|
||||
<h3>Request</h3>
|
||||
<pre>
|
||||
<code>{JSON.stringify(record.request, null, 2)}</code>
|
||||
</pre>
|
||||
</div>
|
||||
<div className="evidence-field">
|
||||
<h3>Response</h3>
|
||||
<pre>
|
||||
<code>
|
||||
{record.response !== null
|
||||
? JSON.stringify(record.response, null, 2)
|
||||
: "No response received."}
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,117 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, within, cleanup } from "@testing-library/react";
|
||||
import { SourceInventory } from "./SourceInventory.js";
|
||||
import type { SourceRecord } from "../app/state.js";
|
||||
|
||||
beforeEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const makeSource = (overrides: Partial<SourceRecord> & { id: string }): SourceRecord => ({
|
||||
kind: "tool",
|
||||
enabled: true,
|
||||
description: null,
|
||||
toolCount: 1,
|
||||
nodeSpecCount: 0,
|
||||
reducerCount: 0,
|
||||
promptCount: 0,
|
||||
resourceCount: 0,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("SourceInventory", () => {
|
||||
it("shows loading state", () => {
|
||||
render(<SourceInventory sources={[]} loading={true} error={null} />);
|
||||
expect(screen.getByTestId("sources-loading")).toHaveTextContent(
|
||||
"Loading sources\u2026",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows empty state when no sources", () => {
|
||||
render(<SourceInventory sources={[]} loading={false} error={null} />);
|
||||
expect(screen.getByTestId("sources-empty")).toHaveTextContent(
|
||||
"No workflow sources reported.",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders source rows with id, kind, and enabled status", () => {
|
||||
const sources = [
|
||||
makeSource({ id: "tools-core", kind: "tool", enabled: true }),
|
||||
makeSource({ id: "reducers-main", kind: "reducer", enabled: false }),
|
||||
];
|
||||
render(<SourceInventory sources={sources} loading={false} error={null} />);
|
||||
|
||||
expect(screen.getByTestId("source-id-tools-core")).toHaveTextContent(
|
||||
"tools-core",
|
||||
);
|
||||
expect(screen.getByTestId("source-kind-tools-core")).toHaveTextContent(
|
||||
"tool",
|
||||
);
|
||||
expect(
|
||||
screen.getByTestId("source-status-tools-core"),
|
||||
).toHaveTextContent("enabled");
|
||||
expect(
|
||||
screen.getByTestId("source-status-reducers-main"),
|
||||
).toHaveTextContent("disabled");
|
||||
});
|
||||
|
||||
it("shows description when present", () => {
|
||||
const sources = [
|
||||
makeSource({
|
||||
id: "tools-core",
|
||||
description: "Core workflow tools",
|
||||
}),
|
||||
];
|
||||
render(<SourceInventory sources={sources} loading={false} error={null} />);
|
||||
expect(screen.getByText("Core workflow tools")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders total counts across categories", () => {
|
||||
const sources = [
|
||||
makeSource({
|
||||
id: "tools-core",
|
||||
toolCount: 5,
|
||||
nodeSpecCount: 3,
|
||||
reducerCount: 2,
|
||||
promptCount: 1,
|
||||
resourceCount: 4,
|
||||
}),
|
||||
];
|
||||
render(<SourceInventory sources={sources} loading={false} error={null} />);
|
||||
|
||||
expect(screen.getByTestId("source-tools-tools-core")).toHaveTextContent("5");
|
||||
expect(
|
||||
screen.getByTestId("source-nodes-tools-core"),
|
||||
).toHaveTextContent("3");
|
||||
expect(
|
||||
screen.getByTestId("source-reducers-tools-core"),
|
||||
).toHaveTextContent("2");
|
||||
expect(
|
||||
screen.getByTestId("source-prompts-tools-core"),
|
||||
).toHaveTextContent("1");
|
||||
expect(
|
||||
screen.getByTestId("source-resources-tools-core"),
|
||||
).toHaveTextContent("4");
|
||||
});
|
||||
|
||||
it("renders error without replacing connection status", () => {
|
||||
render(
|
||||
<SourceInventory
|
||||
sources={[]}
|
||||
loading={false}
|
||||
error="Failed to load sources"
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId("sources-error")).toHaveTextContent(
|
||||
"Failed to load sources",
|
||||
);
|
||||
expect(screen.getByTestId("sources-error")).toHaveAttribute(
|
||||
"role",
|
||||
"alert",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { SourceRecord } from "../app/state.js";
|
||||
|
||||
type Props = {
|
||||
readonly sources: ReadonlyArray<SourceRecord>;
|
||||
readonly loading: boolean;
|
||||
readonly error: string | null;
|
||||
};
|
||||
|
||||
export const SourceInventory = ({ sources, loading, error }: Props) => {
|
||||
return (
|
||||
<section aria-label="Source Inventory">
|
||||
<h2>Sources</h2>
|
||||
{loading && <p data-testid="sources-loading">Loading sources{"\u2026"}</p>}
|
||||
{error && (
|
||||
<p data-testid="sources-error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{!loading && !error && sources.length === 0 && (
|
||||
<p data-testid="sources-empty">No workflow sources reported.</p>
|
||||
)}
|
||||
{!loading && !error && sources.length > 0 && (
|
||||
<table data-testid="sources-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Source</th>
|
||||
<th>Kind</th>
|
||||
<th>Description</th>
|
||||
<th>Status</th>
|
||||
<th>Tools</th>
|
||||
<th>Nodes</th>
|
||||
<th>Reducers</th>
|
||||
<th>Prompts</th>
|
||||
<th>Resources</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sources.map((s) => (
|
||||
<tr key={s.id} data-testid={`source-row-${s.id}`}>
|
||||
<td data-testid={`source-id-${s.id}`}>{s.id}</td>
|
||||
<td data-testid={`source-kind-${s.id}`}>{s.kind}</td>
|
||||
<td data-testid={`source-desc-${s.id}`}>
|
||||
{s.description ?? ""}
|
||||
</td>
|
||||
<td data-testid={`source-status-${s.id}`}>
|
||||
{s.enabled ? "enabled" : "disabled"}
|
||||
</td>
|
||||
<td data-testid={`source-tools-${s.id}`}>{s.toolCount}</td>
|
||||
<td data-testid={`source-nodes-${s.id}`}>{s.nodeSpecCount}</td>
|
||||
<td data-testid={`source-reducers-${s.id}`}>
|
||||
{s.reducerCount}
|
||||
</td>
|
||||
<td data-testid={`source-prompts-${s.id}`}>{s.promptCount}</td>
|
||||
<td data-testid={`source-resources-${s.id}`}>
|
||||
{s.resourceCount}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
declare module "@fontsource-variable/source-sans-3";
|
||||
declare module "@fontsource/barlow-condensed/600.css";
|
||||
declare module "@fontsource/barlow-condensed/700.css";
|
||||
declare module "@fontsource/ibm-plex-mono/400.css";
|
||||
@@ -1,6 +1,11 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./app/App";
|
||||
import "@fontsource/barlow-condensed/600.css";
|
||||
import "@fontsource/barlow-condensed/700.css";
|
||||
import "@fontsource-variable/source-sans-3";
|
||||
import "@fontsource/ibm-plex-mono/400.css";
|
||||
import "./styles/global.css";
|
||||
import { App } from "./app/App.js";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
:root {
|
||||
--color-paper: #faf8f5;
|
||||
--color-ink: #1a1a1a;
|
||||
--color-slate: #5a5a5a;
|
||||
--color-signal-green: #2d8a4e;
|
||||
--color-amber: #b8860b;
|
||||
--color-red: #c0392b;
|
||||
--color-border: #d4d0cb;
|
||||
--color-surface: #f0ede8;
|
||||
--font-heading: "Barlow Condensed", sans-serif;
|
||||
--font-body: "Source Sans 3", sans-serif;
|
||||
--font-mono: "IBM Plex Mono", monospace;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
font-family: var(--font-body);
|
||||
color: var(--color-ink);
|
||||
background-color: var(--color-paper);
|
||||
line-height: 1.5;
|
||||
background-image: linear-gradient(
|
||||
rgba(0, 0, 0, 0.015) 1px,
|
||||
transparent 1px
|
||||
),
|
||||
linear-gradient(90deg, rgba(0, 0, 0, 0.015) 1px, transparent 1px);
|
||||
background-size: 2rem 2rem;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
font-family: var(--font-heading);
|
||||
font-weight: 700;
|
||||
margin: 0 0 0.5rem 0;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.25rem;
|
||||
border-bottom: 2px solid var(--color-border);
|
||||
padding-bottom: 0.25rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-slate);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
section {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* Connection form */
|
||||
form {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: end;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
label {
|
||||
font-family: var(--font-heading);
|
||||
font-weight: 600;
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-slate);
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
input[type="text"] {
|
||||
flex: 1 1 20rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 3px;
|
||||
background: #fff;
|
||||
color: var(--color-ink);
|
||||
}
|
||||
|
||||
input[type="text"]:focus {
|
||||
outline: 2px solid var(--color-signal-green);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: var(--font-heading);
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0.5rem 1.25rem;
|
||||
border: 1px solid var(--color-ink);
|
||||
border-radius: 3px;
|
||||
background: var(--color-ink);
|
||||
color: var(--color-paper);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
background: var(--color-slate);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Status bar */
|
||||
[role="status"] {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-slate);
|
||||
}
|
||||
|
||||
/* Source inventory table */
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
thead th {
|
||||
font-family: var(--font-heading);
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-slate);
|
||||
text-align: left;
|
||||
padding: 0.35rem 0.5rem;
|
||||
border-bottom: 2px solid var(--color-border);
|
||||
}
|
||||
|
||||
tbody td {
|
||||
padding: 0.35rem 0.5rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
/* Evidence drawer */
|
||||
.evidence-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.evidence-list li {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.evidence-list li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.evidence-toggle {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
color: var(--color-ink);
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.85rem;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.evidence-toggle:hover {
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.evidence-op {
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 600;
|
||||
min-width: 12rem;
|
||||
}
|
||||
|
||||
.evidence-label {
|
||||
flex: 1;
|
||||
color: var(--color-slate);
|
||||
}
|
||||
|
||||
.evidence-duration {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-slate);
|
||||
}
|
||||
|
||||
.evidence-detail {
|
||||
padding: 0.75rem;
|
||||
background: var(--color-paper);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 3px;
|
||||
margin-bottom: 0.5rem;
|
||||
animation: fadeIn 0.15s ease;
|
||||
}
|
||||
|
||||
.evidence-field {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.evidence-field:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.evidence-field pre {
|
||||
margin: 0;
|
||||
padding: 0.5rem;
|
||||
background: var(--color-ink);
|
||||
color: var(--color-paper);
|
||||
border-radius: 3px;
|
||||
overflow-x: auto;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Layout grid */
|
||||
.app-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(22rem, 0.7fr);
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.app-layout > section[aria-label="Connection"] {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
/* Motion */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sourceReveal {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
animation: sourceReveal 0.15s ease both;
|
||||
}
|
||||
|
||||
tbody tr:nth-child(1) { animation-delay: 0ms; }
|
||||
tbody tr:nth-child(2) { animation-delay: 30ms; }
|
||||
tbody tr:nth-child(3) { animation-delay: 60ms; }
|
||||
tbody tr:nth-child(4) { animation-delay: 90ms; }
|
||||
tbody tr:nth-child(5) { animation-delay: 120ms; }
|
||||
tbody tr:nth-child(6) { animation-delay: 150ms; }
|
||||
tbody tr:nth-child(7) { animation-delay: 180ms; }
|
||||
tbody tr:nth-child(8) { animation-delay: 210ms; }
|
||||
tbody tr:nth-child(9) { animation-delay: 240ms; }
|
||||
tbody tr:nth-child(10) { animation-delay: 270ms; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.evidence-detail,
|
||||
tbody tr {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 850px) {
|
||||
.app-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
form {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
input[type="text"] {
|
||||
flex-basis: auto;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user