feat: add project-aware agent workspace
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"path": "/agent/obsidian-water-v1.riv",
|
||||
"sha256": "ea556c57d3fd966710fc1c31af09dc706c72f6f2570a82a7e4201cde3c332a00",
|
||||
"byteLength": 9879
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { webcrypto } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import asset from "./persona-resource-manifest.json";
|
||||
|
||||
const bytes = Uint8Array.from(readFileSync(`public${asset.path}`)).buffer;
|
||||
const source = `${asset.path}?v=${asset.sha256}`;
|
||||
const absolute = (path: string) => new URL(path, window.location.href).href;
|
||||
|
||||
function mockStorage() {
|
||||
const entries = new Map<string, Response>();
|
||||
const cache = {
|
||||
match: vi.fn(async (key: string) => entries.get(absolute(key))?.clone()),
|
||||
put: vi.fn(async (key: string, response: Response) => { entries.set(absolute(key), response.clone()); }),
|
||||
keys: vi.fn(async () => Array.from(entries.keys(), (key) => new Request(key))),
|
||||
delete: vi.fn(async (request: Request) => entries.delete(request.url))
|
||||
};
|
||||
vi.stubGlobal("caches", { open: vi.fn().mockResolvedValue(cache) });
|
||||
return { entries, cache };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("crypto", webcrypto);
|
||||
vi.stubGlobal("caches", undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
describe("Obsidian resource", () => {
|
||||
it("shares one download across simultaneous and later renderer mounts", async () => {
|
||||
const fetch = vi.fn().mockResolvedValue(new Response(bytes));
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
const { loadPersonaResource } = await import("./persona-resource");
|
||||
const [first, second] = await Promise.all([loadPersonaResource(), loadPersonaResource()]);
|
||||
expect(new Uint8Array(first)).toEqual(new Uint8Array(bytes));
|
||||
expect(second).toBe(first);
|
||||
expect(await loadPersonaResource()).toBe(first);
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
source,
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) })
|
||||
);
|
||||
});
|
||||
|
||||
it("allows a new mount to retry a failed download", async () => {
|
||||
const fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(new Response(null, { status: 503 }))
|
||||
.mockResolvedValueOnce(new Response(bytes));
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
const { loadPersonaResource } = await import("./persona-resource");
|
||||
await expect(loadPersonaResource()).rejects.toThrow("503");
|
||||
expect(new Uint8Array(await loadPersonaResource())).toEqual(new Uint8Array(bytes));
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("loads from persistent storage after module memory is gone and the network is unavailable", async () => {
|
||||
const { entries } = mockStorage();
|
||||
const fetch = vi.fn().mockResolvedValue(new Response(bytes));
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
await (await import("./persona-resource")).loadPersonaResource();
|
||||
expect(entries.has(absolute(source))).toBe(true);
|
||||
vi.resetModules();
|
||||
fetch.mockRejectedValue(new TypeError("offline"));
|
||||
const restored = await (await import("./persona-resource")).loadPersonaResource();
|
||||
expect(new Uint8Array(restored)).toEqual(new Uint8Array(bytes));
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("fetches a new content version and removes only old entries for this animation", async () => {
|
||||
const { entries } = mockStorage();
|
||||
entries.set(absolute(`${asset.path}?v=old`), new Response("old animation"));
|
||||
entries.set(absolute("/unrelated.riv"), new Response("keep"));
|
||||
const fetch = vi.fn().mockResolvedValue(new Response(bytes));
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
await (await import("./persona-resource")).loadPersonaResource();
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
expect(Array.from(entries.keys()).sort()).toEqual([absolute(source), absolute("/unrelated.riv")].sort());
|
||||
});
|
||||
|
||||
it("replaces corrupted cached bytes even when their size and RIVE header still match", async () => {
|
||||
const { entries } = mockStorage();
|
||||
const corrupted = new Uint8Array(bytes.slice(0));
|
||||
corrupted[100] ^= 1;
|
||||
entries.set(absolute(source), new Response(corrupted));
|
||||
const fetch = vi.fn().mockResolvedValue(new Response(bytes));
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
await (await import("./persona-resource")).loadPersonaResource();
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
expect(new Uint8Array(await entries.get(absolute(source))!.arrayBuffer())).toEqual(new Uint8Array(bytes));
|
||||
});
|
||||
|
||||
it.each(["unavailable", "denied", "full"])("keeps playing when storage is %s", async (failure) => {
|
||||
if (failure === "denied") {
|
||||
vi.stubGlobal("caches", { open: vi.fn().mockRejectedValue(new Error("Storage denied")) });
|
||||
} else if (failure === "full") {
|
||||
mockStorage().cache.put.mockRejectedValue(new Error("Quota exceeded"));
|
||||
}
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(bytes)));
|
||||
expect(new Uint8Array(await (await import("./persona-resource")).loadPersonaResource())).toEqual(new Uint8Array(bytes));
|
||||
});
|
||||
|
||||
it("does not persist an HTML fallback or the wrong resource version and can retry", async () => {
|
||||
const { cache } = mockStorage();
|
||||
const wrongVersion = new Uint8Array(bytes.slice(0));
|
||||
wrongVersion[100] ^= 1;
|
||||
vi.stubGlobal("fetch", vi.fn()
|
||||
.mockResolvedValueOnce(new Response("<!doctype html>"))
|
||||
.mockResolvedValueOnce(new Response(wrongVersion))
|
||||
.mockResolvedValueOnce(new Response(bytes)));
|
||||
const { loadPersonaResource } = await import("./persona-resource");
|
||||
await expect(loadPersonaResource()).rejects.toThrow("invalid animation data");
|
||||
await expect(loadPersonaResource()).rejects.toThrow("version mismatch");
|
||||
expect(cache.put).not.toHaveBeenCalled();
|
||||
expect(new Uint8Array(await loadPersonaResource())).toEqual(new Uint8Array(bytes));
|
||||
expect(cache.put).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import asset from "./persona-resource-manifest.json";
|
||||
|
||||
const source = `${asset.path}?v=${asset.sha256}`;
|
||||
const cacheName = "tjwater-agent-persona-v1";
|
||||
|
||||
let pending: Promise<ArrayBuffer> | undefined;
|
||||
|
||||
async function readResource(response: Response): Promise<ArrayBuffer> {
|
||||
if (!response.ok) throw new Error(`Obsidian resource: ${response.status}`);
|
||||
const bytes = await response.arrayBuffer();
|
||||
const header = new Uint8Array(bytes, 0, Math.min(bytes.byteLength, 4));
|
||||
if (bytes.byteLength !== asset.byteLength || String.fromCharCode(...header) !== "RIVE") {
|
||||
throw new Error("Obsidian resource: invalid animation data");
|
||||
}
|
||||
if (globalThis.crypto?.subtle) {
|
||||
const digest = await crypto.subtle.digest("SHA-256", bytes);
|
||||
const hash = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
if (hash !== asset.sha256) throw new Error("Obsidian resource: version mismatch");
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
async function loadBytes(): Promise<ArrayBuffer> {
|
||||
let cache: Cache | undefined;
|
||||
try {
|
||||
cache = await globalThis.caches?.open(cacheName);
|
||||
const cached = await cache?.match(source);
|
||||
if (cached) return await readResource(cached);
|
||||
} catch {
|
||||
// Storage can be denied or contain incomplete data. Retry through the network.
|
||||
}
|
||||
|
||||
const response = await fetch(source, { signal: AbortSignal.timeout(15_000) });
|
||||
const bytes = await readResource(response);
|
||||
if (cache) {
|
||||
try {
|
||||
await cache.put(source, new Response(bytes, {
|
||||
headers: { "Content-Type": "application/octet-stream" }
|
||||
}));
|
||||
const current = new URL(source, window.location.href);
|
||||
for (const request of await cache.keys()) {
|
||||
const url = new URL(request.url);
|
||||
if (url.origin === current.origin && url.pathname === asset.path && url.href !== current.href) {
|
||||
await cache.delete(request);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Quota or storage failures must not prevent the downloaded animation playing.
|
||||
}
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
// Share the immutable bytes, never a live renderer or View Model instance.
|
||||
export function loadPersonaResource() {
|
||||
pending ??= loadBytes().catch((error: unknown) => {
|
||||
pending = undefined;
|
||||
throw error;
|
||||
});
|
||||
return pending;
|
||||
}
|
||||
@@ -1,36 +1,104 @@
|
||||
import { render, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const riveMocks = vi.hoisted(() => {
|
||||
const bindViewModelInstance = vi.fn();
|
||||
return {
|
||||
bytes: new ArrayBuffer(8),
|
||||
canvas: null as HTMLCanvasElement | null,
|
||||
load: vi.fn(),
|
||||
inputs: Object.fromEntries(
|
||||
["listening", "thinking", "speaking", "asleep", "hover"].map((name) => [
|
||||
name,
|
||||
{ value: false }
|
||||
])
|
||||
),
|
||||
bindViewModelInstance,
|
||||
useRive: vi.fn(() => ({
|
||||
rive: { bindViewModelInstance },
|
||||
RiveComponent: () => <canvas aria-label="Rive Persona" />
|
||||
canvas: riveMocks.canvas,
|
||||
RiveComponent: ({ className }: { className?: string }) => (
|
||||
<canvas className={className} aria-label="Rive Persona" />
|
||||
)
|
||||
}))
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./persona-resource", () => ({ loadPersonaResource: riveMocks.load }));
|
||||
vi.mock("@rive-app/react-webgl2", () => ({
|
||||
EventType: { LoadError: "loaderror" },
|
||||
useRive: riveMocks.useRive,
|
||||
useStateMachineInput: () => null
|
||||
useStateMachineInput: (_rive: unknown, _machine: string, name: string) => riveMocks.inputs[name]
|
||||
}));
|
||||
|
||||
import { Persona } from "./persona";
|
||||
|
||||
describe("Persona", () => {
|
||||
it("loads Obsidian without binding the Rive View Model", async () => {
|
||||
render(<Persona />);
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
riveMocks.load.mockResolvedValue(riveMocks.bytes);
|
||||
riveMocks.canvas = document.createElement("canvas");
|
||||
Object.values(riveMocks.inputs).forEach((input) => {
|
||||
input.value = false;
|
||||
});
|
||||
});
|
||||
|
||||
describe("Persona", () => {
|
||||
it("loads the original bytes after deferred initialization without a View Model or color filter", async () => {
|
||||
const { getByLabelText } = render(<Persona />);
|
||||
expect(riveMocks.useRive).toHaveBeenCalledWith(null);
|
||||
await waitFor(() =>
|
||||
expect(riveMocks.useRive).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
src: "https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/obsidian-2.0.riv",
|
||||
stateMachines: "default"
|
||||
buffer: riveMocks.bytes,
|
||||
stateMachines: "default",
|
||||
isTouchScrollEnabled: true
|
||||
})
|
||||
)
|
||||
);
|
||||
expect(riveMocks.bindViewModelInstance).not.toHaveBeenCalled();
|
||||
expect(getByLabelText("Rive Persona").style.filter).toBe("");
|
||||
});
|
||||
|
||||
it("switches the original state inputs without leaving the previous state enabled", async () => {
|
||||
const { rerender } = render(<Persona state="thinking" />);
|
||||
await waitFor(() => expect(riveMocks.inputs.thinking.value).toBe(true));
|
||||
rerender(<Persona state="speaking" />);
|
||||
expect(riveMocks.inputs.thinking.value).toBe(false);
|
||||
expect(riveMocks.inputs.speaking.value).toBe(true);
|
||||
rerender(<Persona state="idle" />);
|
||||
expect(Object.values(riveMocks.inputs).every((input) => !input.value)).toBe(true);
|
||||
});
|
||||
|
||||
it("forwards mouse coordinates and exit to Rive without turning touch into hover", async () => {
|
||||
const { getByLabelText } = render(<Persona />);
|
||||
await waitFor(() => expect(riveMocks.load).toHaveBeenCalled());
|
||||
const wrapper = getByLabelText("Rive Persona").parentElement!;
|
||||
const move = vi.fn();
|
||||
const exit = vi.fn();
|
||||
riveMocks.canvas!.addEventListener("mousemove", move);
|
||||
riveMocks.canvas!.addEventListener("mouseout", exit);
|
||||
const pointer = (type: string, pointerType: string, x: number) => {
|
||||
const event = new Event(type, { bubbles: true });
|
||||
Object.assign(event, { pointerType, isPrimary: true, clientX: x, clientY: 48 });
|
||||
fireEvent(wrapper, event);
|
||||
};
|
||||
pointer("pointermove", "mouse", 16);
|
||||
pointer("pointermove", "mouse", 48);
|
||||
expect(move).toHaveBeenNthCalledWith(1, expect.objectContaining({ clientX: 16, clientY: 48 }));
|
||||
expect(move).toHaveBeenNthCalledWith(2, expect.objectContaining({ clientX: 48, clientY: 48 }));
|
||||
pointer("pointermove", "touch", 32);
|
||||
pointer("pointerdown", "touch", 32);
|
||||
expect(move).toHaveBeenCalledTimes(2);
|
||||
pointer("pointercancel", "mouse", 48);
|
||||
expect(exit).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("reports a resource failure so the caller can retain the original still", async () => {
|
||||
riveMocks.load.mockRejectedValueOnce(new Error("offline"));
|
||||
const onLoadError = vi.fn();
|
||||
render(<Persona onLoadError={onLoadError} />);
|
||||
await waitFor(() =>
|
||||
expect(onLoadError).toHaveBeenCalledWith({ type: "loaderror", data: "Error: offline" })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { RiveParameters } from "@rive-app/react-webgl2";
|
||||
import { useRive, useStateMachineInput } from "@rive-app/react-webgl2";
|
||||
import { EventType, useRive, useStateMachineInput } from "@rive-app/react-webgl2";
|
||||
import { memo, useEffect, useState } from "react";
|
||||
import type { PointerEvent as ReactPointerEvent } from "react";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import { loadPersonaResource } from "./persona-resource";
|
||||
|
||||
const RIVE_SOURCE = "https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/obsidian-2.0.riv";
|
||||
const STATE_MACHINE = "default";
|
||||
|
||||
function useDeferredRiveInit() {
|
||||
@@ -21,24 +22,45 @@ export type PersonaState = "idle" | "listening" | "thinking" | "speaking" | "asl
|
||||
|
||||
export type PersonaProps = {
|
||||
className?: string;
|
||||
onLoad?: RiveParameters["onLoad"];
|
||||
onLoadError?: RiveParameters["onLoadError"];
|
||||
state?: PersonaState;
|
||||
};
|
||||
|
||||
export const Persona = memo(function Persona({
|
||||
className,
|
||||
onLoad,
|
||||
onLoadError,
|
||||
state = "idle"
|
||||
}: PersonaProps) {
|
||||
const ready = useDeferredRiveInit();
|
||||
const [buffer, setBuffer] = useState<ArrayBuffer>();
|
||||
|
||||
const { rive, RiveComponent } = useRive(
|
||||
ready
|
||||
useEffect(() => {
|
||||
if (!ready) return;
|
||||
let cancelled = false;
|
||||
void loadPersonaResource().then(
|
||||
(bytes) => {
|
||||
if (!cancelled) setBuffer(bytes);
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (!cancelled) onLoadError?.({ type: EventType.LoadError, data: String(error) });
|
||||
}
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [ready, onLoadError]);
|
||||
|
||||
const { rive, canvas, RiveComponent } = useRive(
|
||||
ready && buffer
|
||||
? {
|
||||
autoplay: true,
|
||||
onLoad,
|
||||
onLoadError,
|
||||
src: RIVE_SOURCE,
|
||||
stateMachines: STATE_MACHINE
|
||||
buffer,
|
||||
stateMachines: STATE_MACHINE,
|
||||
isTouchScrollEnabled: true
|
||||
}
|
||||
: null
|
||||
);
|
||||
@@ -63,10 +85,26 @@ export const Persona = memo(function Persona({
|
||||
}
|
||||
}, [state, listeningInput, thinkingInput, speakingInput, asleepInput]);
|
||||
|
||||
const forwardPointer = (event: ReactPointerEvent<HTMLSpanElement>, type: "mouseover" | "mousemove" | "mouseout") => {
|
||||
if (!event.isPrimary || event.pointerType === "touch") return;
|
||||
// Let Rive transform CSS coordinates into artboard coordinates and update
|
||||
// its original pointer constraint. Only forward hover events, never presses
|
||||
// or touch-generated mouse events, so mobile taps retain their button action.
|
||||
canvas?.dispatchEvent(new MouseEvent(type, {
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<RiveComponent
|
||||
className={cn("size-16 shrink-0", className)}
|
||||
style={{ filter: "brightness(0.45) contrast(1.35)" }}
|
||||
/>
|
||||
<span
|
||||
className={cn("relative block size-16 shrink-0", className)}
|
||||
onPointerEnter={(event) => forwardPointer(event, "mouseover")}
|
||||
onPointerMove={(event) => forwardPointer(event, "mousemove")}
|
||||
onPointerLeave={(event) => forwardPointer(event, "mouseout")}
|
||||
onPointerCancel={(event) => forwardPointer(event, "mouseout")}
|
||||
>
|
||||
<RiveComponent className="pointer-events-none size-full" />
|
||||
</span>
|
||||
);
|
||||
});
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ResourceCache } from "./resource-cache";
|
||||
|
||||
describe("login-owned resource cache", () => {
|
||||
it("shares in-flight work, retains fresh values and retries after expiry", async () => {
|
||||
const clock = vi.spyOn(Date, "now").mockReturnValue(100);
|
||||
const cache = new ResourceCache();
|
||||
const fetch = vi.fn(async () => ["project-a"]);
|
||||
const first = cache.load("projects", fetch, 60_000);
|
||||
expect(cache.load("projects", fetch, 60_000)).toBe(first);
|
||||
await first;
|
||||
for (let i = 0; i < 10; i++) await cache.load("projects", fetch, 60_000);
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
clock.mockReturnValue(60_101);
|
||||
await cache.load("projects", fetch, 60_000);
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
clock.mockRestore();
|
||||
});
|
||||
it("retains the last content after refresh failure and briefly caches failures", async () => {
|
||||
const cache = new ResourceCache();
|
||||
cache.set("suggestions", ["原有问题"], 0);
|
||||
const fail = vi.fn(async () => { throw new Error("offline"); });
|
||||
await expect(cache.load("suggestions", fail, 60_000, { errorTtl: 30_000 })).rejects.toThrow("offline");
|
||||
await expect(cache.load("suggestions", fail, 60_000)).rejects.toThrow("offline");
|
||||
expect(cache.peek<string[]>("suggestions")?.value).toEqual(["原有问题"]);
|
||||
expect(fail).toHaveBeenCalledTimes(1);
|
||||
cache.clear();
|
||||
expect(cache.peek("suggestions")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Vendored
+51
@@ -0,0 +1,51 @@
|
||||
export type CachedResource<T> = {
|
||||
value?: T;
|
||||
error?: unknown;
|
||||
expiresAt: number;
|
||||
pending?: Promise<T>;
|
||||
};
|
||||
|
||||
/** Owned by a login session. A consumer's cancellation never cancels a shared request. */
|
||||
export class ResourceCache {
|
||||
private records = new Map<string, CachedResource<unknown>>();
|
||||
|
||||
peek<T>(key: string): CachedResource<T> | undefined {
|
||||
return this.records.get(key) as CachedResource<T> | undefined;
|
||||
}
|
||||
|
||||
set<T>(key: string, value: T, ttl: number) {
|
||||
this.records.set(key, { value, expiresAt: Date.now() + ttl });
|
||||
}
|
||||
|
||||
delete(key: string) { this.records.delete(key); }
|
||||
clear() { this.records.clear(); }
|
||||
updateMatching<T>(matches: (key: string) => boolean, update: (value: T) => T) {
|
||||
for (const [key, record] of this.records) {
|
||||
if (matches(key) && record.value !== undefined) {
|
||||
this.records.set(key, { value: update(record.value as T), expiresAt: 0 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
load<T>(key: string, load: () => Promise<T>, ttl: number, options?: { force?: boolean; errorTtl?: number }): Promise<T> {
|
||||
const previous = this.peek<T>(key);
|
||||
if (previous?.pending) return previous.pending;
|
||||
if (!options?.force && previous && previous.expiresAt > Date.now()) {
|
||||
if (previous.error) return Promise.reject(previous.error);
|
||||
if (previous.value !== undefined) return Promise.resolve(previous.value);
|
||||
}
|
||||
const record: CachedResource<T> = { value: previous?.value, expiresAt: previous?.expiresAt ?? 0 };
|
||||
const pending = Promise.resolve().then(load).then((value) => {
|
||||
if (this.records.get(key) === record) this.set(key, value, ttl);
|
||||
return value;
|
||||
}, (error: unknown) => {
|
||||
if (this.records.get(key) === record) {
|
||||
this.records.set(key, { value: record.value, error, expiresAt: Date.now() + (options?.errorTtl ?? 0) });
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
record.pending = pending;
|
||||
this.records.set(key, record);
|
||||
return pending;
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,15 @@ describe("runtime frontend configuration", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts a same-origin path for the peripheral reverse proxy", () => {
|
||||
expect(
|
||||
parseRuntimeConfig({
|
||||
TJWATER_AUTH_MODE: "disabled",
|
||||
TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL: "/peripheral/"
|
||||
}).TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL
|
||||
).toBe("/peripheral");
|
||||
});
|
||||
|
||||
it("accepts the explicit backend history mode", () => {
|
||||
expect(
|
||||
parseRuntimeConfig({
|
||||
@@ -82,7 +91,8 @@ describe("runtime frontend configuration", () => {
|
||||
["TJWATER_MAP_URL", "file:///etc/passwd"],
|
||||
["TJWATER_AGENT_URL", "https://user:secret@agent.example.test"],
|
||||
["TJWATER_AGENT_URL", "https://agent.example.test/#secret"],
|
||||
["TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL", "https://user:secret@peripheral.example.test"]
|
||||
["TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL", "https://user:secret@peripheral.example.test"],
|
||||
["TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL", "//peripheral.example.test"]
|
||||
])("rejects unsafe browser runtime address %s", (key, value) => {
|
||||
expect(() => parseRuntimeConfig({ TJWATER_AUTH_MODE: "disabled", [key]: value })).toThrow();
|
||||
});
|
||||
|
||||
@@ -35,6 +35,26 @@ const optionalBrowserHttpUrl = z
|
||||
})
|
||||
.transform((value) => value.replace(/\/+$/, ""));
|
||||
|
||||
const optionalBrowserHttpUrlOrSameOriginPath = z
|
||||
.string()
|
||||
.trim()
|
||||
.default("")
|
||||
.superRefine((value, context) => {
|
||||
if (!value) return;
|
||||
if (value.startsWith("/") && !value.startsWith("//")) {
|
||||
const parsed = new URL(value, "https://same-origin.invalid");
|
||||
if (!parsed.search && !parsed.hash && parsed.pathname === value) return;
|
||||
}
|
||||
const parsed = browserHttpUrl.safeParse(value);
|
||||
if (!parsed.success) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "必须是安全的 HTTP(S) 地址或同源绝对路径"
|
||||
});
|
||||
}
|
||||
})
|
||||
.transform((value) => value.replace(/\/+$/, ""));
|
||||
|
||||
const keycloakIssuer = z
|
||||
.string()
|
||||
.trim()
|
||||
@@ -71,7 +91,7 @@ const runtimeConfigSchema = z
|
||||
TJWATER_SERVER_API_BASE_URL: browserHttpUrl.default("http://127.0.0.1:8000"),
|
||||
TJWATER_AGENT_URL: browserHttpUrl.default("http://127.0.0.1:8787"),
|
||||
TJWATER_WORKBENCH_RUNTIME_MODE: z.enum(["auto", "demo", "history", "backend"]).default("auto"),
|
||||
TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL: optionalBrowserHttpUrl,
|
||||
TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL: optionalBrowserHttpUrlOrSameOriginPath,
|
||||
TJWATER_PERIPHERAL_SAMPLE_TIME_MODE: z.enum(["demo", "recent"]).default("demo"),
|
||||
TJWATER_PERIPHERAL_SAMPLE_DEMO_START: z
|
||||
.string()
|
||||
|
||||
@@ -29,10 +29,13 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
||||
overlayClassName?: string
|
||||
showCloseButton?: boolean
|
||||
}
|
||||
>(({ className, children, overlayClassName, showCloseButton = true, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogOverlay className={overlayClassName} />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
@@ -42,10 +45,10 @@ const DialogContent = React.forwardRef<
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
{showCloseButton ? <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Close> : null}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface FieldSelectProps {
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
@@ -28,6 +29,7 @@ export function FieldSelect({
|
||||
placeholder = "请选择",
|
||||
disabled,
|
||||
className,
|
||||
contentClassName,
|
||||
id
|
||||
}: FieldSelectProps) {
|
||||
const generatedId = useId();
|
||||
@@ -53,7 +55,7 @@ export function FieldSelect({
|
||||
<SelectContent
|
||||
position="popper"
|
||||
sideOffset={4}
|
||||
className="z-[90] max-w-[calc(100vw-1.5rem)]"
|
||||
className={cn("z-[90] max-w-[calc(100vw-1.5rem)]", contentClassName)}
|
||||
>
|
||||
{selectableOptions.map((option) => (
|
||||
<SelectItem
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
export type ReadingDocument = { id: string; title: string; host: HTMLDivElement };
|
||||
export type ReadingWorkspace = {
|
||||
active: ReadingDocument | null;
|
||||
view: "focus" | "map_split" | "map_only";
|
||||
open: (document: ReadingDocument) => void;
|
||||
close: (id?: string) => void;
|
||||
setView: (view: ReadingWorkspace["view"]) => void;
|
||||
};
|
||||
export const ReadingWorkspaceContext = createContext<ReadingWorkspace | null>(null);
|
||||
|
||||
export const useReadingWorkspace = () => useContext(ReadingWorkspaceContext);
|
||||
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
useCallback, useEffect, useId, useLayoutEffect,
|
||||
useRef, useState, type ReactNode
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Maximize2 } from "lucide-react";
|
||||
import { Button } from "./button";
|
||||
|
||||
import { ReadingWorkspaceContext as Context, useReadingWorkspace, type ReadingDocument, type ReadingWorkspace } from "./reading-workspace-context";
|
||||
|
||||
export function ReadingWorkspaceProvider({ children }: { children: ReactNode }) {
|
||||
const [active, setActive] = useState<ReadingDocument | null>(null);
|
||||
const [view, setView] = useState<ReadingWorkspace["view"]>("focus");
|
||||
const open = useCallback((document: ReadingDocument) => { setActive(document); setView("focus"); }, []);
|
||||
const close = useCallback((id?: string) => {
|
||||
setActive((current) => !id || current?.id === id ? null : current);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const escape = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape" && !event.defaultPrevented) { event.preventDefault(); close(); }
|
||||
};
|
||||
window.addEventListener("keydown", escape);
|
||||
return () => window.removeEventListener("keydown", escape);
|
||||
}, [active, close]);
|
||||
return <Context.Provider value={{ active, view, open, close, setView }}>{children}</Context.Provider>;
|
||||
}
|
||||
|
||||
|
||||
/** One live subtree moves between the conversation/report and the workspace. */
|
||||
export function ExpandableReading({
|
||||
title, children, summary, enabled = true
|
||||
}: { title: string; children: ReactNode; summary?: ReactNode; enabled?: boolean }) {
|
||||
const workspace = useReadingWorkspace();
|
||||
const id = useId();
|
||||
const [host] = useState(() => document.createElement("div"));
|
||||
const inline = useRef<HTMLDivElement>(null);
|
||||
const trigger = useRef<HTMLButtonElement>(null);
|
||||
const wasActive = useRef(false);
|
||||
const expanded = workspace?.active?.id === id;
|
||||
const close = workspace?.close;
|
||||
useLayoutEffect(() => {
|
||||
if (!expanded && inline.current && host.parentElement !== inline.current) inline.current.append(host);
|
||||
if (wasActive.current && !expanded) trigger.current?.focus({ preventScroll: true });
|
||||
wasActive.current = expanded;
|
||||
}, [expanded, host, enabled]);
|
||||
useEffect(() => () => close?.(id), [close, id]);
|
||||
if (!workspace || !enabled) return <>{children}</>;
|
||||
return <section className="min-w-0" data-expandable-reading>
|
||||
<div className="mb-2 flex items-center justify-end">
|
||||
<Button ref={trigger} size="sm" variant="ghost" className="h-9 gap-1.5 text-xs text-muted-foreground"
|
||||
onClick={() => workspace.open({ id, title, host })}>
|
||||
<Maximize2 size={14} aria-hidden />{expanded ? "返回展开内容" : "展开查看"}
|
||||
</Button>
|
||||
</div>
|
||||
{expanded ? <div className="rounded-lg border bg-muted/30 p-3 text-xs leading-6 text-muted-foreground">
|
||||
{summary ?? `${title}已在工作区展开。`}
|
||||
</div> : null}
|
||||
<div ref={inline} className={expanded ? "hidden" : "min-w-0"} />
|
||||
{createPortal(children, host)}
|
||||
</section>;
|
||||
}
|
||||
|
||||
export function ReadingWorkspaceContent() {
|
||||
const workspace = useReadingWorkspace();
|
||||
const slot = useRef<HTMLDivElement>(null);
|
||||
const host = workspace?.active?.host;
|
||||
useLayoutEffect(() => {
|
||||
if (!host || !slot.current) return;
|
||||
slot.current.append(host);
|
||||
host.tabIndex = -1;
|
||||
host.style.outline = "none";
|
||||
host.focus({ preventScroll: true });
|
||||
const scroll = slot.current.closest<HTMLElement>("[data-reading-scroll]");
|
||||
if (scroll) scroll.scrollTop = Number(host.dataset.readingScroll ?? 0);
|
||||
return () => { if (scroll) host.dataset.readingScroll = String(scroll.scrollTop); };
|
||||
}, [host]);
|
||||
return <div ref={slot} className="min-w-0" />;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { GripVertical } from "lucide-react";
|
||||
import * as ResizablePrimitive from "react-resizable-panels";
|
||||
import { cn } from "./cn";
|
||||
|
||||
// shadcn/ui composition, adapted to this Vite application's paths and tokens.
|
||||
export function ResizablePanelGroup({ className, ...props }: ResizablePrimitive.GroupProps) {
|
||||
return (
|
||||
<ResizablePrimitive.Group
|
||||
data-slot="resizable-panel-group"
|
||||
className={cn(
|
||||
"flex h-full w-full min-h-0 min-w-0 aria-[orientation=vertical]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ResizablePanel(props: ResizablePrimitive.PanelProps) {
|
||||
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />;
|
||||
}
|
||||
|
||||
export function ResizableHandle({
|
||||
withHandle,
|
||||
className,
|
||||
...props
|
||||
}: ResizablePrimitive.SeparatorProps & {
|
||||
withHandle?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ResizablePrimitive.Separator
|
||||
data-slot="resizable-handle"
|
||||
className={cn(
|
||||
"resizable-handle relative flex w-px items-center justify-center bg-border focus-visible:outline-hidden aria-[orientation=horizontal]:h-px aria-[orientation=horizontal]:w-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{withHandle ? (
|
||||
<span className="resizable-grip pointer-events-none z-10 flex h-8 w-3 shrink-0 items-center justify-center rounded border bg-background">
|
||||
<GripVertical className="size-3" aria-hidden />
|
||||
</span>
|
||||
) : null}
|
||||
</ResizablePrimitive.Separator>
|
||||
);
|
||||
}
|
||||
+40
-24
@@ -1,6 +1,7 @@
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
||||
import { motion, useReducedMotion } from "motion/react"
|
||||
|
||||
import { cn } from "@/shared/ui/cn"
|
||||
|
||||
@@ -67,34 +68,49 @@ SelectScrollDownButton.displayName =
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-select-content-transform-origin)",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content> & {
|
||||
/** Opt into Motion; false keeps the same rendering without animation. */
|
||||
animateContent?: boolean
|
||||
}
|
||||
>(({ className, children, position = "popper", animateContent, ...props }, ref) => {
|
||||
const reducedMotion = useReducedMotion()
|
||||
const useMotion = animateContent !== undefined
|
||||
const instant = reducedMotion || !animateContent
|
||||
// Radix Viewport owns scrolling; the outer surface only clips animated overflow.
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"p-1",
|
||||
"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md origin-(--radix-select-content-transform-origin)",
|
||||
!useMotion && "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
asChild={useMotion}
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
{useMotion ? <motion.div
|
||||
initial={instant ? false : { opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: instant ? 0 : 0.16, ease: [0.22, 1, 0.36, 1] }}
|
||||
>{children}</motion.div> : children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
})
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Check, Circle, LoaderCircle, RefreshCw, TriangleAlert } from "lucide-react";
|
||||
import { Button } from "./button";
|
||||
import { Card, CardContent, CardHeader } from "./card";
|
||||
import { StatusIcon } from "./status";
|
||||
|
||||
export type StartupStep = {
|
||||
id: string;
|
||||
label: string;
|
||||
detail: string;
|
||||
state: "waiting" | "loading" | "complete";
|
||||
};
|
||||
|
||||
type StartupScreenProps = {
|
||||
brand: { name: string; description: string; icon: ReactNode };
|
||||
title: string;
|
||||
description: string;
|
||||
progressLabel: string;
|
||||
steps: StartupStep[];
|
||||
error?: string | null;
|
||||
retryLabel?: string;
|
||||
onRetry?: () => void;
|
||||
};
|
||||
|
||||
const stepTone = { waiting: "neutral", loading: "info", complete: "success" } as const;
|
||||
const stepStateLabel = { waiting: "等待中", loading: "进行中", complete: "已完成" };
|
||||
|
||||
export function StartupScreen({ brand, title, description, progressLabel, steps, error, retryLabel = "重新加载", onRetry }: StartupScreenProps) {
|
||||
return (
|
||||
<main aria-label="应用启动" className="grid min-h-dvh place-items-center bg-[var(--surface-dock)] px-4 py-8 text-foreground">
|
||||
<Card data-slot="startup-panel" className="surface-reading w-full max-w-md rounded-2xl border-[var(--surface-border)] shadow-none">
|
||||
<CardHeader className="space-y-0 p-6 pb-0 sm:p-8 sm:pb-0">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="shrink-0 text-[var(--action-blue)] [&>svg]:size-5 [&>svg]:fill-[var(--action-blue)]" aria-hidden>{brand.icon}</span>
|
||||
<span><span className="font-semibold text-foreground">{brand.name}</span><span className="mx-2 text-[var(--surface-border)]" aria-hidden>/</span>{brand.description}</span>
|
||||
</div>
|
||||
<div role="status" aria-live="polite" aria-atomic="true" className="pt-6">
|
||||
<h1 className="text-xl font-semibold leading-7">{title}</h1>
|
||||
<p className="mt-2 text-xs leading-5 text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6 pt-6 sm:p-8 sm:pt-6">
|
||||
{error ? (
|
||||
<>
|
||||
<div role="alert" className="flex items-start gap-3 rounded-xl border border-border bg-[var(--surface-control)] p-3">
|
||||
<StatusIcon tone="danger"><TriangleAlert size={14} /></StatusIcon>
|
||||
<p className="min-w-0 flex-1 py-0.5 text-xs leading-5 text-foreground [overflow-wrap:anywhere]">{error}</p>
|
||||
</div>
|
||||
{onRetry ? <Button type="button" className="mt-6 h-10 w-full" onClick={onRetry}>
|
||||
<RefreshCw size={14} aria-hidden />{retryLabel}
|
||||
</Button> : null}
|
||||
</>
|
||||
) : (
|
||||
<ol aria-label={progressLabel} className="divide-y divide-[var(--surface-divider)] rounded-xl border border-[var(--surface-divider)] bg-[var(--surface-control)] px-4">
|
||||
{steps.map((step) => <li key={step.id} className="flex items-start gap-3 py-4" aria-current={step.state === "loading" ? "step" : undefined}>
|
||||
<StatusIcon tone={stepTone[step.state]} activity={step.state === "loading" ? "loading" : "static"}>
|
||||
{step.state === "loading" ? <LoaderCircle size={14} /> : step.state === "complete" ? <Check size={14} /> : <Circle size={12} />}
|
||||
</StatusIcon>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium leading-6">{step.label}<span className="sr-only">,{stepStateLabel[step.state]}</span></p>
|
||||
<p className="mt-0.5 whitespace-pre-line text-xs leading-5 text-muted-foreground [overflow-wrap:anywhere]">{step.detail}</p>
|
||||
</div>
|
||||
</li>)}
|
||||
</ol>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import {
|
||||
forwardRef,
|
||||
type HTMLAttributes,
|
||||
type TableHTMLAttributes,
|
||||
type ThHTMLAttributes,
|
||||
type TdHTMLAttributes
|
||||
} from "react";
|
||||
import { cn } from "./cn";
|
||||
|
||||
const Table = forwardRef<HTMLTableElement, TableHTMLAttributes<HTMLTableElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn("w-full caption-bottom border-collapse text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
Table.displayName = "Table";
|
||||
|
||||
const TableHeader = forwardRef<HTMLTableSectionElement, HTMLAttributes<HTMLTableSectionElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||
)
|
||||
);
|
||||
TableHeader.displayName = "TableHeader";
|
||||
|
||||
const TableBody = forwardRef<HTMLTableSectionElement, HTMLAttributes<HTMLTableSectionElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<tbody ref={ref} className={cn("[&_tr:last-child]:border-0", className)} {...props} />
|
||||
)
|
||||
);
|
||||
TableBody.displayName = "TableBody";
|
||||
|
||||
const TableRow = forwardRef<HTMLTableRowElement, HTMLAttributes<HTMLTableRowElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn("border-b border-border data-[state=selected]:bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
TableRow.displayName = "TableRow";
|
||||
|
||||
const TableHead = forwardRef<HTMLTableCellElement, ThHTMLAttributes<HTMLTableCellElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-10 px-3 text-left align-middle text-xs font-medium text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
TableHead.displayName = "TableHead";
|
||||
|
||||
const TableCell = forwardRef<HTMLTableCellElement, TdHTMLAttributes<HTMLTableCellElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<td ref={ref} className={cn("p-3 align-top text-xs tabular-nums", className)} {...props} />
|
||||
)
|
||||
);
|
||||
TableCell.displayName = "TableCell";
|
||||
|
||||
const TableCaption = forwardRef<HTMLTableCaptionElement, HTMLAttributes<HTMLTableCaptionElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn("px-3 py-2 text-left text-xs text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
TableCaption.displayName = "TableCaption";
|
||||
|
||||
export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell, TableCaption };
|
||||
@@ -0,0 +1,52 @@
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
import { forwardRef, type ComponentPropsWithoutRef, type ElementRef } from "react";
|
||||
import { cn } from "./cn";
|
||||
|
||||
const Tabs = TabsPrimitive.Root;
|
||||
|
||||
const TabsList = forwardRef<
|
||||
ElementRef<typeof TabsPrimitive.List>,
|
||||
ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex min-h-10 items-center gap-1 rounded-lg bg-muted p-1 text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsList.displayName = "TabsList";
|
||||
|
||||
const TabsTrigger = forwardRef<
|
||||
ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex min-h-10 items-center justify-center gap-1.5 whitespace-nowrap rounded-md px-3 py-1.5 text-xs font-medium focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-card data-[state=active]:text-foreground data-[state=active]:shadow-xs motion-safe:active:scale-[0.97]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsTrigger.displayName = "TabsTrigger";
|
||||
|
||||
const TabsContent = forwardRef<
|
||||
ElementRef<typeof TabsPrimitive.Content>,
|
||||
ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"min-w-0 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsContent.displayName = "TabsContent";
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
Reference in New Issue
Block a user