feat: restructure as pnpm monorepo with Tauri desktop shell
- Migrate to packages/web + packages/desktop workspace layout via git mv - Add Tauri v2 desktop shell with @tauri-apps/plugin-http for CORS bypass - Configure Turborepo with package-level dependsOn build graph - Add semantic-release with exec plugin for GHA output and disabled PR comments - Fix http:default capability scope to allow all HTTP/HTTPS origins - Add Vite Tauri integration (clearScreen, TAURI_DEV_HOST, target, envPrefix) - Add semantic-release.yml and release.yml GitHub Actions workflows - Fix all Biome lint errors (noArrayIndexKey, noNonNullAssertion, button types)
This commit is contained in:
25
packages/web/src/api/client.ts
Normal file
25
packages/web/src/api/client.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { loadConfig } from "@/lib/config";
|
||||
import { httpFetch } from "@/lib/http";
|
||||
import createClient from "openapi-fetch";
|
||||
import type { paths } from "./schema.d.ts";
|
||||
|
||||
export function createHonchoClient() {
|
||||
const config = loadConfig();
|
||||
const baseUrl = config?.baseUrl ?? "http://localhost:8000";
|
||||
const token = config?.token ?? "";
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
return createClient<paths>({ baseUrl, headers, fetch: httpFetch });
|
||||
}
|
||||
|
||||
export const client = {
|
||||
get current() {
|
||||
return createHonchoClient();
|
||||
},
|
||||
};
|
||||
36
packages/web/src/api/keys.ts
Normal file
36
packages/web/src/api/keys.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export const QK = {
|
||||
workspaces: (page: number, size: number) => ["workspaces", page, size] as const,
|
||||
workspace: (id: string) => ["workspace", id] as const,
|
||||
queueStatus: (wsId: string) => ["queue-status", wsId] as const,
|
||||
workspaceSearch: (wsId: string, q: string) => ["workspace-search", wsId, q] as const,
|
||||
|
||||
peers: (wsId: string, page: number, size: number) => ["peers", wsId, page, size] as const,
|
||||
peer: (wsId: string, pId: string) => ["peer", wsId, pId] as const,
|
||||
peerRepresentation: (wsId: string, pId: string, target?: string) =>
|
||||
["peer-representation", wsId, pId, target] as const,
|
||||
peerCard: (wsId: string, pId: string) => ["peer-card", wsId, pId] as const,
|
||||
peerContext: (wsId: string, pId: string) => ["peer-context", wsId, pId] as const,
|
||||
peerSessions: (wsId: string, pId: string, page: number, size: number) =>
|
||||
["peer-sessions", wsId, pId, page, size] as const,
|
||||
|
||||
sessions: (wsId: string, page: number, size: number) => ["sessions", wsId, page, size] as const,
|
||||
session: (wsId: string, sId: string) => ["session", wsId, sId] as const,
|
||||
sessionMessages: (wsId: string, sId: string, page: number, size: number) =>
|
||||
["session-messages", wsId, sId, page, size] as const,
|
||||
sessionSummaries: (wsId: string, sId: string) => ["session-summaries", wsId, sId] as const,
|
||||
sessionContext: (wsId: string, sId: string) => ["session-context", wsId, sId] as const,
|
||||
sessionPeers: (wsId: string, sId: string) => ["session-peers", wsId, sId] as const,
|
||||
peerConfig: (wsId: string, sId: string, pId: string) => ["peer-config", wsId, sId, pId] as const,
|
||||
|
||||
conclusions: (
|
||||
wsId: string,
|
||||
filters: Record<string, unknown>,
|
||||
page: number,
|
||||
size: number,
|
||||
reverse?: boolean,
|
||||
) => ["conclusions", wsId, filters, page, size, reverse] as const,
|
||||
conclusionsQuery: (wsId: string, q: string, filters: Record<string, unknown>) =>
|
||||
["conclusions-query", wsId, q, filters] as const,
|
||||
|
||||
webhooks: (wsId: string) => ["webhooks", wsId] as const,
|
||||
};
|
||||
765
packages/web/src/api/queries.ts
Normal file
765
packages/web/src/api/queries.ts
Normal file
@@ -0,0 +1,765 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { client } from "./client";
|
||||
import { QK } from "./keys";
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function err(e: unknown): never {
|
||||
throw new Error(typeof e === "object" ? JSON.stringify(e) : String(e));
|
||||
}
|
||||
|
||||
// ─── Workspaces ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function useWorkspaces(page = 1, pageSize = 20) {
|
||||
return useQuery({
|
||||
queryKey: QK.workspaces(page, pageSize),
|
||||
queryFn: async () => {
|
||||
const { data, error } = await client.current.POST("/v3/workspaces/list", {
|
||||
params: { query: { page, page_size: pageSize } },
|
||||
body: {},
|
||||
});
|
||||
return data ?? err(error);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useWorkspace(workspaceId: string) {
|
||||
return useQuery({
|
||||
queryKey: QK.workspace(workspaceId),
|
||||
queryFn: async () => {
|
||||
const { data, error } = await client.current.POST("/v3/workspaces", {
|
||||
body: { id: workspaceId, metadata: {} },
|
||||
});
|
||||
return data ?? err(error);
|
||||
},
|
||||
enabled: Boolean(workspaceId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateWorkspace(workspaceId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (body: { metadata?: Record<string, unknown> }) => {
|
||||
const { data, error } = await client.current.PUT("/v3/workspaces/{workspace_id}", {
|
||||
params: { path: { workspace_id: workspaceId } },
|
||||
body,
|
||||
});
|
||||
return data ?? err(error);
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["workspace", workspaceId] });
|
||||
qc.invalidateQueries({ queryKey: ["workspaces"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteWorkspace() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (workspaceId: string) => {
|
||||
const { error } = await client.current.DELETE("/v3/workspaces/{workspace_id}", {
|
||||
params: { path: { workspace_id: workspaceId } },
|
||||
});
|
||||
if (error) err(error);
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["workspaces"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useScheduleDream(workspaceId: string) {
|
||||
return useMutation({
|
||||
mutationFn: async (body: {
|
||||
observer: string;
|
||||
observed?: string | null;
|
||||
dream_type: "omni";
|
||||
session_id?: string | null;
|
||||
}) => {
|
||||
const { error } = await client.current.POST("/v3/workspaces/{workspace_id}/schedule_dream", {
|
||||
params: { path: { workspace_id: workspaceId } },
|
||||
body,
|
||||
});
|
||||
if (error) err(error);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useQueueStatus(workspaceId: string) {
|
||||
return useQuery({
|
||||
queryKey: QK.queueStatus(workspaceId),
|
||||
queryFn: async () => {
|
||||
const { data, error } = await client.current.GET(
|
||||
"/v3/workspaces/{workspace_id}/queue/status",
|
||||
{ params: { path: { workspace_id: workspaceId } } },
|
||||
);
|
||||
return data ?? err(error);
|
||||
},
|
||||
enabled: Boolean(workspaceId),
|
||||
refetchInterval: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSearchWorkspace(workspaceId: string, query: string, enabled = false) {
|
||||
return useQuery({
|
||||
queryKey: QK.workspaceSearch(workspaceId, query),
|
||||
queryFn: async () => {
|
||||
const { data, error } = await client.current.POST("/v3/workspaces/{workspace_id}/search", {
|
||||
params: { path: { workspace_id: workspaceId } },
|
||||
body: { query, limit: 20 },
|
||||
});
|
||||
return data ?? err(error);
|
||||
},
|
||||
enabled: enabled && Boolean(workspaceId) && Boolean(query),
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Peers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function usePeers(workspaceId: string, page = 1, pageSize = 20) {
|
||||
return useQuery({
|
||||
queryKey: QK.peers(workspaceId, page, pageSize),
|
||||
queryFn: async () => {
|
||||
const { data, error } = await client.current.POST(
|
||||
"/v3/workspaces/{workspace_id}/peers/list",
|
||||
{
|
||||
params: { path: { workspace_id: workspaceId }, query: { page, page_size: pageSize } },
|
||||
body: {},
|
||||
},
|
||||
);
|
||||
return data ?? err(error);
|
||||
},
|
||||
enabled: Boolean(workspaceId),
|
||||
});
|
||||
}
|
||||
|
||||
export function usePeer(workspaceId: string, peerId: string) {
|
||||
return useQuery({
|
||||
queryKey: QK.peer(workspaceId, peerId),
|
||||
queryFn: async () => {
|
||||
const { data, error } = await client.current.POST("/v3/workspaces/{workspace_id}/peers", {
|
||||
params: { path: { workspace_id: workspaceId } },
|
||||
body: { id: peerId, metadata: {} },
|
||||
});
|
||||
return data ?? err(error);
|
||||
},
|
||||
enabled: Boolean(workspaceId) && Boolean(peerId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdatePeer(workspaceId: string, peerId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (body: { metadata?: Record<string, unknown> }) => {
|
||||
const { data, error } = await client.current.PUT(
|
||||
"/v3/workspaces/{workspace_id}/peers/{peer_id}",
|
||||
{ params: { path: { workspace_id: workspaceId, peer_id: peerId } }, body },
|
||||
);
|
||||
return data ?? err(error);
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["peer", workspaceId, peerId] });
|
||||
qc.invalidateQueries({ queryKey: ["peers", workspaceId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function usePeerRepresentation(workspaceId: string, peerId: string, target?: string) {
|
||||
return useQuery({
|
||||
queryKey: QK.peerRepresentation(workspaceId, peerId, target),
|
||||
queryFn: async () => {
|
||||
const { data, error } = await client.current.POST(
|
||||
"/v3/workspaces/{workspace_id}/peers/{peer_id}/representation",
|
||||
{
|
||||
params: { path: { workspace_id: workspaceId, peer_id: peerId } },
|
||||
body: { max_conclusions: 20, ...(target ? { target } : {}) },
|
||||
},
|
||||
);
|
||||
return data ?? err(error);
|
||||
},
|
||||
enabled: Boolean(workspaceId) && Boolean(peerId),
|
||||
});
|
||||
}
|
||||
|
||||
export function usePeerCard(workspaceId: string, peerId: string) {
|
||||
return useQuery({
|
||||
queryKey: QK.peerCard(workspaceId, peerId),
|
||||
queryFn: async () => {
|
||||
const { data, error } = await client.current.GET(
|
||||
"/v3/workspaces/{workspace_id}/peers/{peer_id}/card",
|
||||
{ params: { path: { workspace_id: workspaceId, peer_id: peerId } } },
|
||||
);
|
||||
return data ?? err(error);
|
||||
},
|
||||
enabled: Boolean(workspaceId) && Boolean(peerId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetPeerCard(workspaceId: string, peerId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (peerCard: string[]) => {
|
||||
const { data, error } = await client.current.PUT(
|
||||
"/v3/workspaces/{workspace_id}/peers/{peer_id}/card",
|
||||
{
|
||||
params: { path: { workspace_id: workspaceId, peer_id: peerId } },
|
||||
body: { peer_card: peerCard },
|
||||
},
|
||||
);
|
||||
return data ?? err(error);
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: QK.peerCard(workspaceId, peerId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function usePeerContext(workspaceId: string, peerId: string) {
|
||||
return useQuery({
|
||||
queryKey: QK.peerContext(workspaceId, peerId),
|
||||
queryFn: async () => {
|
||||
const { data, error } = await client.current.GET(
|
||||
"/v3/workspaces/{workspace_id}/peers/{peer_id}/context",
|
||||
{ params: { path: { workspace_id: workspaceId, peer_id: peerId } } },
|
||||
);
|
||||
return data ?? err(error);
|
||||
},
|
||||
enabled: Boolean(workspaceId) && Boolean(peerId),
|
||||
});
|
||||
}
|
||||
|
||||
export function usePeerSessions(workspaceId: string, peerId: string, page = 1, pageSize = 20) {
|
||||
return useQuery({
|
||||
queryKey: QK.peerSessions(workspaceId, peerId, page, pageSize),
|
||||
queryFn: async () => {
|
||||
const { data, error } = await client.current.POST(
|
||||
"/v3/workspaces/{workspace_id}/peers/{peer_id}/sessions",
|
||||
{
|
||||
params: {
|
||||
path: { workspace_id: workspaceId, peer_id: peerId },
|
||||
query: { page, page_size: pageSize },
|
||||
},
|
||||
body: {},
|
||||
},
|
||||
);
|
||||
return data ?? err(error);
|
||||
},
|
||||
enabled: Boolean(workspaceId) && Boolean(peerId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSearchPeer(workspaceId: string, peerId: string) {
|
||||
return useMutation({
|
||||
mutationFn: async (query: string) => {
|
||||
const { data, error } = await client.current.POST(
|
||||
"/v3/workspaces/{workspace_id}/peers/{peer_id}/search",
|
||||
{
|
||||
params: { path: { workspace_id: workspaceId, peer_id: peerId } },
|
||||
body: { query, limit: 20 },
|
||||
},
|
||||
);
|
||||
return data ?? err(error);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useChat(workspaceId: string, peerId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (message: string) => {
|
||||
const { data, error } = await client.current.POST(
|
||||
"/v3/workspaces/{workspace_id}/peers/{peer_id}/chat",
|
||||
{
|
||||
params: { path: { workspace_id: workspaceId, peer_id: peerId } },
|
||||
body: { query: message, stream: false, reasoning_level: "low" },
|
||||
},
|
||||
);
|
||||
return data ?? err(error);
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["peer-context", workspaceId, peerId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Sessions ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useSessions(workspaceId: string, page = 1, pageSize = 20) {
|
||||
return useQuery({
|
||||
queryKey: QK.sessions(workspaceId, page, pageSize),
|
||||
queryFn: async () => {
|
||||
const { data, error } = await client.current.POST(
|
||||
"/v3/workspaces/{workspace_id}/sessions/list",
|
||||
{
|
||||
params: {
|
||||
path: { workspace_id: workspaceId },
|
||||
query: { page, page_size: pageSize },
|
||||
},
|
||||
body: {},
|
||||
},
|
||||
);
|
||||
return data ?? err(error);
|
||||
},
|
||||
enabled: Boolean(workspaceId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateSession(workspaceId: string, sessionId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (body: { metadata?: Record<string, unknown> }) => {
|
||||
const { data, error } = await client.current.PUT(
|
||||
"/v3/workspaces/{workspace_id}/sessions/{session_id}",
|
||||
{ params: { path: { workspace_id: workspaceId, session_id: sessionId } }, body },
|
||||
);
|
||||
return data ?? err(error);
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["sessions", workspaceId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteSession(workspaceId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (sessionId: string) => {
|
||||
const { error } = await client.current.DELETE(
|
||||
"/v3/workspaces/{workspace_id}/sessions/{session_id}",
|
||||
{ params: { path: { workspace_id: workspaceId, session_id: sessionId } } },
|
||||
);
|
||||
if (error) err(error);
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["sessions", workspaceId] });
|
||||
qc.invalidateQueries({ queryKey: ["peer-sessions", workspaceId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCloneSession(workspaceId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (sessionId: string) => {
|
||||
const { data, error } = await client.current.POST(
|
||||
"/v3/workspaces/{workspace_id}/sessions/{session_id}/clone",
|
||||
{ params: { path: { workspace_id: workspaceId, session_id: sessionId } } },
|
||||
);
|
||||
return data ?? err(error);
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["sessions", workspaceId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useSearchSession(workspaceId: string, sessionId: string) {
|
||||
return useMutation({
|
||||
mutationFn: async (query: string) => {
|
||||
const { data, error } = await client.current.POST(
|
||||
"/v3/workspaces/{workspace_id}/sessions/{session_id}/search",
|
||||
{
|
||||
params: { path: { workspace_id: workspaceId, session_id: sessionId } },
|
||||
body: { query, limit: 20 },
|
||||
},
|
||||
);
|
||||
return data ?? err(error);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useSessionMessages(
|
||||
workspaceId: string,
|
||||
sessionId: string,
|
||||
page = 1,
|
||||
pageSize = 50,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: QK.sessionMessages(workspaceId, sessionId, page, pageSize),
|
||||
queryFn: async () => {
|
||||
const { data, error } = await client.current.POST(
|
||||
"/v3/workspaces/{workspace_id}/sessions/{session_id}/messages/list",
|
||||
{
|
||||
params: {
|
||||
path: { workspace_id: workspaceId, session_id: sessionId },
|
||||
query: { page, page_size: pageSize },
|
||||
},
|
||||
body: {},
|
||||
},
|
||||
);
|
||||
return data ?? err(error);
|
||||
},
|
||||
enabled: Boolean(workspaceId) && Boolean(sessionId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateMessages(workspaceId: string, sessionId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (
|
||||
messages: Array<{ content: string; peer_id: string; metadata?: Record<string, unknown> }>,
|
||||
) => {
|
||||
const { data, error } = await client.current.POST(
|
||||
"/v3/workspaces/{workspace_id}/sessions/{session_id}/messages",
|
||||
{
|
||||
params: { path: { workspace_id: workspaceId, session_id: sessionId } },
|
||||
body: { messages },
|
||||
},
|
||||
);
|
||||
return data ?? err(error);
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["session-messages", workspaceId, sessionId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateMessage(workspaceId: string, sessionId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
messageId,
|
||||
body,
|
||||
}: {
|
||||
messageId: string;
|
||||
body: { metadata?: Record<string, unknown> };
|
||||
}) => {
|
||||
const { data, error } = await client.current.PUT(
|
||||
"/v3/workspaces/{workspace_id}/sessions/{session_id}/messages/{message_id}",
|
||||
{
|
||||
params: {
|
||||
path: {
|
||||
workspace_id: workspaceId,
|
||||
session_id: sessionId,
|
||||
message_id: messageId,
|
||||
},
|
||||
},
|
||||
body,
|
||||
},
|
||||
);
|
||||
return data ?? err(error);
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["session-messages", workspaceId, sessionId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Session ↔ Peer membership ────────────────────────────────────────────────
|
||||
|
||||
export function useSessionPeers(workspaceId: string, sessionId: string) {
|
||||
return useQuery({
|
||||
queryKey: QK.sessionPeers(workspaceId, sessionId),
|
||||
queryFn: async () => {
|
||||
const { data, error } = await client.current.GET(
|
||||
"/v3/workspaces/{workspace_id}/sessions/{session_id}/peers",
|
||||
{ params: { path: { workspace_id: workspaceId, session_id: sessionId } } },
|
||||
);
|
||||
return data ?? err(error);
|
||||
},
|
||||
enabled: Boolean(workspaceId) && Boolean(sessionId),
|
||||
});
|
||||
}
|
||||
|
||||
type SessionPeerConfigMap = Record<
|
||||
string,
|
||||
{ observe_me?: boolean | null; observe_others?: boolean | null }
|
||||
>;
|
||||
|
||||
export function useAddPeersToSession(workspaceId: string, sessionId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (peers: SessionPeerConfigMap) => {
|
||||
const { data, error } = await client.current.POST(
|
||||
"/v3/workspaces/{workspace_id}/sessions/{session_id}/peers",
|
||||
{
|
||||
params: { path: { workspace_id: workspaceId, session_id: sessionId } },
|
||||
body: peers,
|
||||
},
|
||||
);
|
||||
return data ?? err(error);
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["session-peers", workspaceId, sessionId] });
|
||||
qc.invalidateQueries({ queryKey: ["peer-sessions", workspaceId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetSessionPeers(workspaceId: string, sessionId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (peers: SessionPeerConfigMap) => {
|
||||
const { data, error } = await client.current.PUT(
|
||||
"/v3/workspaces/{workspace_id}/sessions/{session_id}/peers",
|
||||
{
|
||||
params: { path: { workspace_id: workspaceId, session_id: sessionId } },
|
||||
body: peers,
|
||||
},
|
||||
);
|
||||
return data ?? err(error);
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["session-peers", workspaceId, sessionId] });
|
||||
qc.invalidateQueries({ queryKey: ["peer-sessions", workspaceId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRemovePeersFromSession(workspaceId: string, sessionId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (peerIds: string[]) => {
|
||||
const { error } = await client.current.DELETE(
|
||||
"/v3/workspaces/{workspace_id}/sessions/{session_id}/peers",
|
||||
{
|
||||
params: { path: { workspace_id: workspaceId, session_id: sessionId } },
|
||||
body: peerIds,
|
||||
},
|
||||
);
|
||||
if (error) err(error);
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["session-peers", workspaceId, sessionId] });
|
||||
qc.invalidateQueries({ queryKey: ["peer-sessions", workspaceId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function usePeerConfig(workspaceId: string, sessionId: string, peerId: string) {
|
||||
return useQuery({
|
||||
queryKey: QK.peerConfig(workspaceId, sessionId, peerId),
|
||||
queryFn: async () => {
|
||||
const { data, error } = await client.current.GET(
|
||||
"/v3/workspaces/{workspace_id}/sessions/{session_id}/peers/{peer_id}/config",
|
||||
{
|
||||
params: {
|
||||
path: { workspace_id: workspaceId, session_id: sessionId, peer_id: peerId },
|
||||
},
|
||||
},
|
||||
);
|
||||
return data ?? err(error);
|
||||
},
|
||||
enabled: Boolean(workspaceId) && Boolean(sessionId) && Boolean(peerId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetPeerConfig(workspaceId: string, sessionId: string, peerId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (config: Record<string, unknown>) => {
|
||||
const { data, error } = await client.current.PUT(
|
||||
"/v3/workspaces/{workspace_id}/sessions/{session_id}/peers/{peer_id}/config",
|
||||
{
|
||||
params: {
|
||||
path: { workspace_id: workspaceId, session_id: sessionId, peer_id: peerId },
|
||||
},
|
||||
body: config,
|
||||
},
|
||||
);
|
||||
return data ?? err(error);
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: QK.peerConfig(workspaceId, sessionId, peerId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Session summaries & context ──────────────────────────────────────────────
|
||||
|
||||
export function useSessionSummaries(workspaceId: string, sessionId: string) {
|
||||
return useQuery({
|
||||
queryKey: QK.sessionSummaries(workspaceId, sessionId),
|
||||
queryFn: async () => {
|
||||
const { data, error } = await client.current.GET(
|
||||
"/v3/workspaces/{workspace_id}/sessions/{session_id}/summaries",
|
||||
{ params: { path: { workspace_id: workspaceId, session_id: sessionId } } },
|
||||
);
|
||||
return data ?? err(error);
|
||||
},
|
||||
enabled: Boolean(workspaceId) && Boolean(sessionId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSessionContext(workspaceId: string, sessionId: string) {
|
||||
return useQuery({
|
||||
queryKey: QK.sessionContext(workspaceId, sessionId),
|
||||
queryFn: async () => {
|
||||
const { data, error } = await client.current.GET(
|
||||
"/v3/workspaces/{workspace_id}/sessions/{session_id}/context",
|
||||
{ params: { path: { workspace_id: workspaceId, session_id: sessionId } } },
|
||||
);
|
||||
return data ?? err(error);
|
||||
},
|
||||
enabled: Boolean(workspaceId) && Boolean(sessionId),
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Conclusions ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function useConclusions(
|
||||
workspaceId: string,
|
||||
filters: Record<string, unknown> = {},
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
reverse = false,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: QK.conclusions(workspaceId, filters, page, pageSize, reverse),
|
||||
queryFn: async () => {
|
||||
const { data, error } = await client.current.POST(
|
||||
"/v3/workspaces/{workspace_id}/conclusions/list",
|
||||
{
|
||||
params: {
|
||||
path: { workspace_id: workspaceId },
|
||||
query: { page, page_size: pageSize, reverse },
|
||||
},
|
||||
body: filters,
|
||||
},
|
||||
);
|
||||
return data ?? err(error);
|
||||
},
|
||||
enabled: Boolean(workspaceId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useQueryConclusions(
|
||||
workspaceId: string,
|
||||
query: string,
|
||||
filters: Record<string, unknown> = {},
|
||||
enabled = false,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: QK.conclusionsQuery(workspaceId, query, filters),
|
||||
queryFn: async () => {
|
||||
const { data, error } = await client.current.POST(
|
||||
"/v3/workspaces/{workspace_id}/conclusions/query",
|
||||
{
|
||||
params: { path: { workspace_id: workspaceId } },
|
||||
body: { query, top_k: 10, ...filters },
|
||||
},
|
||||
);
|
||||
return data ?? err(error);
|
||||
},
|
||||
enabled: enabled && Boolean(workspaceId) && Boolean(query),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateConclusion(workspaceId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (conclusion: {
|
||||
observer_id: string;
|
||||
observed_id: string;
|
||||
content: string;
|
||||
session_id?: string | null;
|
||||
}) => {
|
||||
const { data, error } = await client.current.POST(
|
||||
"/v3/workspaces/{workspace_id}/conclusions",
|
||||
{
|
||||
params: { path: { workspace_id: workspaceId } },
|
||||
body: { conclusions: [conclusion] },
|
||||
},
|
||||
);
|
||||
return data ?? err(error);
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["conclusions", workspaceId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteConclusion(workspaceId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (conclusionId: string) => {
|
||||
const { error } = await client.current.DELETE(
|
||||
"/v3/workspaces/{workspace_id}/conclusions/{conclusion_id}",
|
||||
{
|
||||
params: {
|
||||
path: { workspace_id: workspaceId, conclusion_id: conclusionId },
|
||||
},
|
||||
},
|
||||
);
|
||||
if (error) err(error);
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["conclusions", workspaceId] });
|
||||
qc.invalidateQueries({ queryKey: ["conclusions-query", workspaceId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Webhooks ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useWebhooks(workspaceId: string) {
|
||||
return useQuery({
|
||||
queryKey: QK.webhooks(workspaceId),
|
||||
queryFn: async () => {
|
||||
const { data, error } = await client.current.GET("/v3/workspaces/{workspace_id}/webhooks", {
|
||||
params: { path: { workspace_id: workspaceId } },
|
||||
});
|
||||
return data ?? err(error);
|
||||
},
|
||||
enabled: Boolean(workspaceId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateWebhook(workspaceId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (url: string) => {
|
||||
const { data, error } = await client.current.POST("/v3/workspaces/{workspace_id}/webhooks", {
|
||||
params: { path: { workspace_id: workspaceId } },
|
||||
body: { url },
|
||||
});
|
||||
return data ?? err(error);
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: QK.webhooks(workspaceId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteWebhook(workspaceId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (endpointId: string) => {
|
||||
const { error } = await client.current.DELETE(
|
||||
"/v3/workspaces/{workspace_id}/webhooks/{endpoint_id}",
|
||||
{
|
||||
params: {
|
||||
path: { workspace_id: workspaceId, endpoint_id: endpointId },
|
||||
},
|
||||
},
|
||||
);
|
||||
if (error) err(error);
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: QK.webhooks(workspaceId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useTestWebhook(workspaceId: string) {
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
const { data, error } = await client.current.GET(
|
||||
"/v3/workspaces/{workspace_id}/webhooks/test",
|
||||
{ params: { path: { workspace_id: workspaceId } } },
|
||||
);
|
||||
return data ?? err(error);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Keys ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useCreateKey() {
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
const { data, error } = await client.current.POST("/v3/keys", {});
|
||||
return data ?? err(error);
|
||||
},
|
||||
});
|
||||
}
|
||||
3324
packages/web/src/api/schema.d.ts
vendored
Normal file
3324
packages/web/src/api/schema.d.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
200
packages/web/src/components/chat/ChatPage.tsx
Normal file
200
packages/web/src/components/chat/ChatPage.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import { useChat } from "@/api/queries";
|
||||
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/input";
|
||||
import { SectionHeading } from "@/components/ui/typography";
|
||||
import { Link, useParams } from "@tanstack/react-router";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { Brain, Send } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
}
|
||||
|
||||
export function ChatPage() {
|
||||
const { workspaceId, peerId } = useParams({ strict: false }) as {
|
||||
workspaceId: string;
|
||||
peerId: string;
|
||||
};
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [input, setInput] = useState("");
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const chatMutation = useChat(workspaceId, peerId);
|
||||
|
||||
useEffect(() => {
|
||||
if (messages.length > 0) {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
async function handleSend() {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed || chatMutation.isPending) return;
|
||||
|
||||
setInput("");
|
||||
setMessages((prev) => [...prev, { id: crypto.randomUUID(), role: "user", content: trimmed }]);
|
||||
|
||||
try {
|
||||
const result = await chatMutation.mutateAsync(trimmed);
|
||||
const responseText =
|
||||
(result as { content?: string | null }).content ??
|
||||
(typeof result === "string" ? result : JSON.stringify(result));
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ id: crypto.randomUUID(), role: "assistant", content: responseText },
|
||||
]);
|
||||
} catch (err) {
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
role: "assistant",
|
||||
content: `Error: ${err instanceof Error ? err.message : "Unknown error"}`,
|
||||
},
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen" style={{ background: "var(--bg)" }}>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="shrink-0 px-6 py-4"
|
||||
style={{ borderBottom: "1px solid var(--border)", background: "var(--bg-2)" }}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-xs mb-1" style={{ color: "var(--text-3)" }}>
|
||||
<Link
|
||||
to="/workspaces/$workspaceId/peers/$peerId"
|
||||
params={{ workspaceId, peerId } as never}
|
||||
className="hover:underline font-mono"
|
||||
>
|
||||
{peerId}
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span>Chat</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Brain className="w-4 h-4" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
|
||||
<SectionHeading as="h1" className="mb-0">
|
||||
Memory-augmented chat
|
||||
</SectionHeading>
|
||||
</div>
|
||||
<p className="text-xs mt-0.5" style={{ color: "var(--text-3)" }}>
|
||||
Honcho responds using accumulated context for <span className="font-mono">{peerId}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-auto px-4 sm:px-6 py-4 space-y-4">
|
||||
<AnimatePresence initial={false}>
|
||||
{messages.length === 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="flex items-center justify-center h-full"
|
||||
>
|
||||
<div className="text-center">
|
||||
<div
|
||||
className="w-14 h-14 rounded-2xl flex items-center justify-center mx-auto mb-4"
|
||||
style={{
|
||||
background: "var(--accent-dim)",
|
||||
border: "1px solid var(--accent-border)",
|
||||
}}
|
||||
>
|
||||
<Brain className="w-6 h-6" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
|
||||
</div>
|
||||
<p className="text-sm font-medium" style={{ color: "var(--text-2)" }}>
|
||||
Start a conversation
|
||||
</p>
|
||||
<p className="text-xs mt-1 max-w-xs" style={{ color: "var(--text-3)" }}>
|
||||
Honcho will respond using accumulated memory context for this peer
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{messages.map((msg) => (
|
||||
<motion.div
|
||||
key={msg.id}
|
||||
initial={{ opacity: 0, y: 8, scale: 0.97 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 25 }}
|
||||
className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}
|
||||
>
|
||||
<div
|
||||
className="max-w-[80%] sm:max-w-[70%] rounded-2xl px-4 py-3 text-sm"
|
||||
style={
|
||||
msg.role === "user"
|
||||
? { background: "var(--accent)", color: "#fff" }
|
||||
: {
|
||||
background: "var(--bg-2)",
|
||||
border: "1px solid var(--border)",
|
||||
color: "var(--text-2)",
|
||||
}
|
||||
}
|
||||
>
|
||||
<p className="whitespace-pre-wrap leading-relaxed">{msg.content}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
{chatMutation.isPending && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="flex justify-start"
|
||||
>
|
||||
<div
|
||||
className="rounded-2xl px-4 py-3 flex items-center gap-2"
|
||||
style={{ background: "var(--bg-2)", border: "1px solid var(--border)" }}
|
||||
>
|
||||
<LoadingSpinner size="sm" />
|
||||
<span className="text-xs" style={{ color: "var(--text-3)" }}>
|
||||
Honcho is thinking...
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div
|
||||
className="shrink-0 px-4 sm:px-6 py-4"
|
||||
style={{ borderTop: "1px solid var(--border)", background: "var(--bg-2)" }}
|
||||
>
|
||||
<div className="flex gap-3 max-w-3xl mx-auto">
|
||||
<Textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Message this peer... (Enter to send, Shift+Enter for newline)"
|
||||
rows={2}
|
||||
className="flex-1 resize-none"
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleSend}
|
||||
disabled={!input.trim() || chatMutation.isPending}
|
||||
className="self-end mb-0.5"
|
||||
>
|
||||
<Send className="w-4 h-4" strokeWidth={1.5} />
|
||||
<span className="hidden sm:block">Send</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
419
packages/web/src/components/conclusions/ConclusionBrowser.tsx
Normal file
419
packages/web/src/components/conclusions/ConclusionBrowser.tsx
Normal file
@@ -0,0 +1,419 @@
|
||||
import {
|
||||
useConclusions,
|
||||
useCreateConclusion,
|
||||
useDeleteConclusion,
|
||||
useQueryConclusions,
|
||||
} from "@/api/queries";
|
||||
import type { components } from "@/api/schema.d.ts";
|
||||
import { ConfirmDialog } from "@/components/shared/ConfirmDialog";
|
||||
import { EmptyState } from "@/components/shared/EmptyState";
|
||||
import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
||||
import { FormModal } from "@/components/shared/FormModal";
|
||||
import { PageLoader } from "@/components/shared/LoadingSpinner";
|
||||
import { Pagination } from "@/components/shared/Pagination";
|
||||
import { SortControl, type SortDir } from "@/components/shared/SortControl";
|
||||
import { TimestampChip } from "@/components/shared/TimestampChip";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input, Textarea } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Body, Caption, MonoCaption, Muted, PageTitle } from "@/components/ui/typography";
|
||||
import { COLOR } from "@/lib/constants";
|
||||
import { Link, useParams } from "@tanstack/react-router";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { ArrowLeft, Eye, Lightbulb, Plus, Search, Trash2, X } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
type Conclusion = components["schemas"]["Conclusion"];
|
||||
|
||||
const createSchema = z.object({
|
||||
observer_id: z.string().min(1, "Observer peer ID is required"),
|
||||
observed_id: z.string().min(1, "Observed peer ID is required"),
|
||||
content: z.string().min(1, "Content is required"),
|
||||
session_id: z.string().optional(),
|
||||
});
|
||||
|
||||
const SORT_OPTIONS = [
|
||||
{ value: "created_at", label: "Date" },
|
||||
{ value: "observer_id", label: "Observer" },
|
||||
{ value: "observed_id", label: "Observed" },
|
||||
];
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 8 },
|
||||
show: (i: number) => ({
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { delay: i * 0.04, type: "spring" as const, stiffness: 300, damping: 25 },
|
||||
}),
|
||||
};
|
||||
|
||||
export function ConclusionBrowser() {
|
||||
const { workspaceId } = useParams({ strict: false }) as { workspaceId: string };
|
||||
const [page, setPage] = useState(1);
|
||||
const [sortField, setSortField] = useState("created_at");
|
||||
const [sortDir, setSortDir] = useState<SortDir>("desc");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [activeSearch, setActiveSearch] = useState("");
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
||||
|
||||
// created_at uses server-side reverse; other fields use client-side sort
|
||||
const serverReverse = sortField === "created_at" && sortDir === "asc";
|
||||
const { data, isLoading, error } = useConclusions(workspaceId, {}, page, 20, serverReverse);
|
||||
const { data: searchResults, isLoading: searchLoading } = useQueryConclusions(
|
||||
workspaceId,
|
||||
activeSearch,
|
||||
{},
|
||||
Boolean(activeSearch),
|
||||
);
|
||||
const createConclusion = useCreateConclusion(workspaceId);
|
||||
const deleteConclusion = useDeleteConclusion(workspaceId);
|
||||
|
||||
const conclusions: Conclusion[] = (data as { items?: Conclusion[] } | undefined)?.items ?? [];
|
||||
const totalPages = (data as { pages?: number } | undefined)?.pages ?? 1;
|
||||
const total = (data as { total?: number } | undefined)?.total ?? 0;
|
||||
|
||||
const sortedConclusions = useMemo(() => {
|
||||
if (sortField === "created_at") return conclusions; // server handles this
|
||||
return [...conclusions].sort((a, b) => {
|
||||
const cmp =
|
||||
sortField === "observer_id"
|
||||
? a.observer_id.localeCompare(b.observer_id)
|
||||
: (a.observed_id ?? "").localeCompare(b.observed_id ?? "");
|
||||
return sortDir === "asc" ? cmp : -cmp;
|
||||
});
|
||||
}, [conclusions, sortField, sortDir]);
|
||||
|
||||
const displayedConclusions: Conclusion[] = activeSearch
|
||||
? Array.isArray(searchResults)
|
||||
? searchResults
|
||||
: []
|
||||
: sortedConclusions;
|
||||
|
||||
function handleSort(field: string, dir: SortDir) {
|
||||
setSortField(field);
|
||||
setSortDir(dir);
|
||||
setPage(1);
|
||||
}
|
||||
|
||||
function handleSearch(e: React.SyntheticEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setActiveSearch(searchQuery.trim());
|
||||
setPage(1);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }} className="mb-8">
|
||||
<Link
|
||||
to="/workspaces/$workspaceId"
|
||||
params={{ workspaceId } as never}
|
||||
className="inline-flex items-center gap-1.5 text-xs mb-4 transition-colors"
|
||||
style={{ color: "var(--text-3)" }}
|
||||
>
|
||||
<ArrowLeft className="w-3 h-3" strokeWidth={1.5} />
|
||||
{workspaceId}
|
||||
</Link>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Lightbulb className="w-5 h-5" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
|
||||
<PageTitle>Conclusions</PageTitle>
|
||||
{total > 0 && !activeSearch && (
|
||||
<span
|
||||
className="ml-1 text-xs font-mono px-2 py-0.5 rounded-full"
|
||||
style={{
|
||||
background: COLOR.accentSubtle,
|
||||
color: COLOR.accentText,
|
||||
border: `1px solid ${COLOR.accentBorder}`,
|
||||
}}
|
||||
>
|
||||
{total}
|
||||
</span>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{!activeSearch && (
|
||||
<SortControl
|
||||
options={SORT_OPTIONS}
|
||||
field={sortField}
|
||||
dir={sortDir}
|
||||
onChange={handleSort}
|
||||
/>
|
||||
)}
|
||||
<Button variant="accent" size="sm" onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="w-3.5 h-3.5" strokeWidth={2} />
|
||||
New
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Muted className="mt-0.5">Distilled memory observations about peers</Muted>
|
||||
</motion.div>
|
||||
|
||||
{/* Search */}
|
||||
<form onSubmit={handleSearch} className="flex gap-2 mb-6">
|
||||
<div className="relative flex-1">
|
||||
<Search
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4"
|
||||
style={{ color: "var(--text-4)" }}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Semantic search across conclusions..."
|
||||
className="rounded-xl pl-9 pr-4 py-2.5 font-mono"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" variant="primary" className="rounded-xl">
|
||||
Search
|
||||
</Button>
|
||||
<AnimatePresence>
|
||||
{activeSearch && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="surface"
|
||||
onClick={() => {
|
||||
setActiveSearch("");
|
||||
setSearchQuery("");
|
||||
}}
|
||||
className="rounded-xl"
|
||||
>
|
||||
<X className="w-4 h-4" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</form>
|
||||
|
||||
<ErrorAlert error={error instanceof Error ? error : null} />
|
||||
{(isLoading || (activeSearch && searchLoading)) && <PageLoader />}
|
||||
|
||||
{!isLoading && !searchLoading && displayedConclusions.length === 0 && (
|
||||
<EmptyState
|
||||
icon={Lightbulb}
|
||||
title={activeSearch ? "No results found" : "No conclusions yet"}
|
||||
description={
|
||||
activeSearch
|
||||
? `No conclusions match "${activeSearch}"`
|
||||
: "Conclusions are created when Honcho processes sessions."
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{displayedConclusions.length > 0 && (
|
||||
<>
|
||||
{activeSearch && (
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="text-xs font-mono mb-3"
|
||||
style={{ color: "var(--text-4)" }}
|
||||
>
|
||||
{displayedConclusions.length} result{displayedConclusions.length !== 1 ? "s" : ""} for
|
||||
“{activeSearch}”
|
||||
</motion.p>
|
||||
)}
|
||||
<div className="space-y-3">
|
||||
{displayedConclusions.map((c, i) => (
|
||||
<motion.div
|
||||
key={c.id}
|
||||
custom={i}
|
||||
variants={itemVariants}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
className="group rounded-xl p-5"
|
||||
style={{
|
||||
background: "var(--surface)",
|
||||
border: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<Body className="whitespace-pre-wrap flex-1">{c.content}</Body>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setDeleteTarget(c.id)}
|
||||
className="opacity-0 group-hover:opacity-100 flex-shrink-0"
|
||||
aria-label="Delete conclusion"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center gap-3 mt-4 pt-3"
|
||||
style={{ borderTop: "1px solid var(--border)" }}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Eye className="w-3 h-3" style={{ color: "var(--text-4)" }} strokeWidth={1.5} />
|
||||
<MonoCaption>{c.observer_id}</MonoCaption>
|
||||
</div>
|
||||
{c.observed_id && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Caption>→</Caption>
|
||||
<MonoCaption>{c.observed_id}</MonoCaption>
|
||||
</div>
|
||||
)}
|
||||
{c.session_id && (
|
||||
<Link
|
||||
to={"/workspaces/$workspaceId/sessions/$sessionId" as never}
|
||||
params={{ workspaceId, sessionId: c.session_id } as never}
|
||||
onClick={(e: React.MouseEvent) => e.stopPropagation()}
|
||||
className="flex items-center gap-1 text-xs font-mono hover:underline"
|
||||
style={{ color: "var(--accent-text)" }}
|
||||
>
|
||||
{c.session_id}
|
||||
</Link>
|
||||
)}
|
||||
{c.created_at && (
|
||||
<div className="ml-auto">
|
||||
<TimestampChip
|
||||
value={c.created_at.replace("T", " ").replace(/\.\d+Z?$/, "")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
{!activeSearch && (
|
||||
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<CreateConclusionModal
|
||||
open={createOpen}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onSubmit={async (values) => {
|
||||
await createConclusion.mutateAsync(values);
|
||||
setCreateOpen(false);
|
||||
}}
|
||||
loading={createConclusion.isPending}
|
||||
error={createConclusion.error?.message}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(deleteTarget)}
|
||||
title="Delete conclusion"
|
||||
description="This conclusion will be permanently removed."
|
||||
confirmLabel="Delete"
|
||||
onConfirm={async () => {
|
||||
if (deleteTarget) await deleteConclusion.mutateAsync(deleteTarget);
|
||||
setDeleteTarget(null);
|
||||
}}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
loading={deleteConclusion.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateConclusionModal({
|
||||
open,
|
||||
onClose,
|
||||
onSubmit,
|
||||
loading,
|
||||
error,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (v: {
|
||||
observer_id: string;
|
||||
observed_id: string;
|
||||
content: string;
|
||||
session_id?: string | null;
|
||||
}) => Promise<void>;
|
||||
loading: boolean;
|
||||
error?: string;
|
||||
}) {
|
||||
const [fields, setFields] = useState({
|
||||
observer_id: "",
|
||||
observed_id: "",
|
||||
content: "",
|
||||
session_id: "",
|
||||
});
|
||||
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const set =
|
||||
(k: keyof typeof fields) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
|
||||
setFields((f) => ({ ...f, [k]: e.target.value }));
|
||||
|
||||
const handleSubmit = async (e: React.SyntheticEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const result = createSchema.safeParse(fields);
|
||||
if (!result.success) {
|
||||
const errs: Record<string, string> = {};
|
||||
for (const issue of result.error.errors) errs[issue.path[0] as string] = issue.message;
|
||||
setValidationErrors(errs);
|
||||
return;
|
||||
}
|
||||
setValidationErrors({});
|
||||
await onSubmit({
|
||||
...result.data,
|
||||
session_id: result.data.session_id ?? null,
|
||||
});
|
||||
setFields({ observer_id: "", observed_id: "", content: "", session_id: "" });
|
||||
};
|
||||
|
||||
return (
|
||||
<FormModal open={open} title="New conclusion" onClose={onClose}>
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
{(["observer_id", "observed_id"] as const).map((field) => (
|
||||
<div key={field}>
|
||||
<Label className="mb-1">
|
||||
{field === "observer_id" ? "Observer peer ID" : "Observed peer ID"}{" "}
|
||||
<span style={{ color: COLOR.destructive }}>*</span>
|
||||
</Label>
|
||||
<Input value={fields[field]} onChange={set(field)} placeholder="peer_id" />
|
||||
{validationErrors[field] && (
|
||||
<p className="text-xs mt-1" style={{ color: COLOR.destructive }}>
|
||||
{validationErrors[field]}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div>
|
||||
<Label className="mb-1">
|
||||
Content <span style={{ color: COLOR.destructive }}>*</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
value={fields.content}
|
||||
onChange={set("content")}
|
||||
rows={4}
|
||||
placeholder="The conclusion content…"
|
||||
className="resize-y"
|
||||
/>
|
||||
{validationErrors.content && (
|
||||
<p className="text-xs mt-1" style={{ color: COLOR.destructive }}>
|
||||
{validationErrors.content}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1">
|
||||
Session ID <span style={{ color: "var(--text-4)" }}>(optional)</span>
|
||||
</Label>
|
||||
<Input value={fields.session_id} onChange={set("session_id")} placeholder="session_id" />
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-xs" style={{ color: COLOR.destructive }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button type="button" variant="surface" size="sm" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="accent" size="sm" disabled={loading}>
|
||||
{loading ? "Creating..." : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</FormModal>
|
||||
);
|
||||
}
|
||||
266
packages/web/src/components/dashboard/Dashboard.tsx
Normal file
266
packages/web/src/components/dashboard/Dashboard.tsx
Normal file
@@ -0,0 +1,266 @@
|
||||
import { useQueueStatus, useWorkspaces } from "@/api/queries";
|
||||
import type { components } from "@/api/schema.d.ts";
|
||||
import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
||||
import { PageLoader } from "@/components/shared/LoadingSpinner";
|
||||
import { Body, Muted, PageTitle, SectionHeading } from "@/components/ui/typography";
|
||||
import { COLOR } from "@/lib/constants";
|
||||
import { formatCount } from "@/lib/utils";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { motion } from "framer-motion";
|
||||
import { Activity, Boxes, ChevronRight, CircleDot, LayoutDashboard } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
type QueueStatus = components["schemas"]["QueueStatus"];
|
||||
|
||||
// ─── Per-workspace queue row ─────────────────────────────────────────────────
|
||||
|
||||
function WorkspaceQueueRow({ workspaceId }: { workspaceId: string }) {
|
||||
const { data, isLoading } = useQueueStatus(workspaceId);
|
||||
|
||||
const pending = data?.pending_work_units ?? 0;
|
||||
const active = data?.in_progress_work_units ?? 0;
|
||||
const done = data?.completed_work_units ?? 0;
|
||||
const total = data?.total_work_units ?? 0;
|
||||
const isActive = active > 0 || pending > 0;
|
||||
|
||||
return (
|
||||
<tr
|
||||
style={{
|
||||
borderTop: "1px solid var(--border)",
|
||||
background: isActive ? COLOR.warningDim : undefined,
|
||||
}}
|
||||
>
|
||||
<td className="py-2 px-4">
|
||||
<Link
|
||||
to="/workspaces/$workspaceId"
|
||||
params={{ workspaceId } as never}
|
||||
className="flex items-center gap-2 group"
|
||||
>
|
||||
<span
|
||||
className="font-mono text-xs truncate max-w-[200px] group-hover:underline"
|
||||
style={{ color: "var(--accent-text)" }}
|
||||
>
|
||||
{workspaceId}
|
||||
</span>
|
||||
<ChevronRight
|
||||
className="w-3 h-3 opacity-0 group-hover:opacity-60 transition-opacity flex-shrink-0"
|
||||
style={{ color: "var(--accent)" }}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</Link>
|
||||
</td>
|
||||
|
||||
<td className="py-2 px-4 text-right">
|
||||
{isLoading ? (
|
||||
<span className="text-xs font-mono" style={{ color: "var(--text-4)" }}>
|
||||
…
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
{isActive ? (
|
||||
<motion.div
|
||||
animate={{ opacity: [0.5, 1, 0.5] }}
|
||||
transition={{ duration: 1.5, repeat: Number.POSITIVE_INFINITY }}
|
||||
>
|
||||
<CircleDot className="w-3 h-3" style={{ color: COLOR.warning }} strokeWidth={2} />
|
||||
</motion.div>
|
||||
) : (
|
||||
<CircleDot className="w-3 h-3" style={{ color: COLOR.success }} strokeWidth={2} />
|
||||
)}
|
||||
<span
|
||||
className="text-xs font-medium"
|
||||
style={{ color: isActive ? COLOR.warning : COLOR.success }}
|
||||
>
|
||||
{isActive ? `${formatCount(pending + active)} pending` : "Idle"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{(
|
||||
[
|
||||
{ val: total, color: "var(--text-2)" },
|
||||
{ val: done, color: COLOR.success },
|
||||
{ val: active, color: COLOR.warning },
|
||||
{ val: pending, color: "var(--text-3)" },
|
||||
] as Array<{ val: number; color: string }>
|
||||
).map(({ val, color }, i) => (
|
||||
<td
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: static positional columns
|
||||
key={i}
|
||||
className="py-2 px-4 text-right font-mono text-xs"
|
||||
style={{ color: isLoading ? "var(--text-4)" : color }}
|
||||
>
|
||||
{isLoading ? "—" : formatCount(val)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Aggregate banner ─────────────────────────────────────────────────────────
|
||||
// Each workspace row already called useQueueStatus — TanStack Query deduplicates
|
||||
// the fetches so calling the same hooks here just reads from cache.
|
||||
|
||||
function GlobalQueueBanner({ workspaces }: { workspaces: Array<{ id: string }> }) {
|
||||
const statuses = workspaces.map((ws) => {
|
||||
const { data } = useQueueStatus(ws.id);
|
||||
return data as QueueStatus | undefined;
|
||||
});
|
||||
|
||||
const totalPending = statuses.reduce((s, d) => s + (d?.pending_work_units ?? 0), 0);
|
||||
const totalActive = statuses.reduce((s, d) => s + (d?.in_progress_work_units ?? 0), 0);
|
||||
const totalDone = statuses.reduce((s, d) => s + (d?.completed_work_units ?? 0), 0);
|
||||
const allLoaded = statuses.every((d) => d !== undefined);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
{(
|
||||
[
|
||||
{ label: "Workspaces", value: workspaces.length, color: "var(--text-1)", always: true },
|
||||
{ label: "Total done", value: totalDone, color: COLOR.success, always: false },
|
||||
{ label: "Active", value: totalActive, color: COLOR.warning, always: false },
|
||||
{
|
||||
label: "Pending",
|
||||
value: totalPending,
|
||||
color: totalPending > 0 ? COLOR.warning : "var(--text-3)",
|
||||
always: false,
|
||||
},
|
||||
] as Array<{ label: string; value: number; color: string; always: boolean }>
|
||||
).map(({ label, value, color, always }) => (
|
||||
<div key={label} className="rounded-xl p-4 theme-card">
|
||||
<div
|
||||
className="text-2xl font-semibold font-mono"
|
||||
style={{ color: allLoaded || always ? color : "var(--text-4)" }}
|
||||
>
|
||||
{allLoaded || always ? formatCount(value) : "—"}
|
||||
</div>
|
||||
<div className="text-xs mt-0.5" style={{ color: "var(--text-3)" }}>
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main dashboard ───────────────────────────────────────────────────────────
|
||||
|
||||
export function Dashboard() {
|
||||
const [page] = useState(1);
|
||||
const { data, isLoading, error } = useWorkspaces(page, 50);
|
||||
|
||||
const workspaces =
|
||||
(data as { items?: Array<{ id: string; created_at?: string }> } | undefined)?.items ?? [];
|
||||
const total = (data as { total?: number } | undefined)?.total ?? 0;
|
||||
|
||||
return (
|
||||
<div className="page-container page-container--xl">
|
||||
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }} className="mb-8">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<LayoutDashboard
|
||||
className="w-5 h-5"
|
||||
style={{ color: "var(--accent)" }}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<PageTitle>Dashboard</PageTitle>
|
||||
{total > 0 && (
|
||||
<span
|
||||
className="ml-1 text-xs font-mono px-2 py-0.5 rounded-full"
|
||||
style={{
|
||||
background: COLOR.accentSubtle,
|
||||
color: COLOR.accentText,
|
||||
border: `1px solid ${COLOR.accentBorder}`,
|
||||
}}
|
||||
>
|
||||
{total} workspace{total !== 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Body className="leading-none">Overview of your Honcho instance</Body>
|
||||
</motion.div>
|
||||
|
||||
<ErrorAlert error={error instanceof Error ? error : null} />
|
||||
{isLoading && <PageLoader />}
|
||||
|
||||
{!isLoading && workspaces.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{/* Aggregate stat row */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.05 }}
|
||||
>
|
||||
<GlobalQueueBanner workspaces={workspaces} />
|
||||
</motion.div>
|
||||
|
||||
{/* Per-workspace queue table */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.12 }}
|
||||
className="rounded-xl theme-card overflow-hidden"
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-2 px-4 py-3"
|
||||
style={{ borderBottom: "1px solid var(--border)" }}
|
||||
>
|
||||
<Activity className="w-4 h-4" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
|
||||
<SectionHeading className="mb-0">Queue Status</SectionHeading>
|
||||
<span className="text-xs ml-1" style={{ color: "var(--text-4)" }}>
|
||||
all workspaces · updates every 10s
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr style={{ background: "var(--bg-3)" }}>
|
||||
{["Workspace", "Status", "Total", "Done", "Active", "Pending"].map((h) => (
|
||||
<th
|
||||
key={h}
|
||||
className={`py-2 px-4 font-medium text-left ${h !== "Workspace" && h !== "Status" ? "text-right" : ""}`}
|
||||
style={{ color: "var(--text-3)" }}
|
||||
>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{workspaces.map((ws) => (
|
||||
<WorkspaceQueueRow key={ws.id} workspaceId={ws.id} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{total > workspaces.length && (
|
||||
<p className="text-xs text-center" style={{ color: "var(--text-4)" }}>
|
||||
Showing {workspaces.length} of {total} workspaces.{" "}
|
||||
<Link
|
||||
to="/workspaces"
|
||||
className="hover:underline"
|
||||
style={{ color: "var(--accent-text)" }}
|
||||
>
|
||||
View all →
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && workspaces.length === 0 && (
|
||||
<div className="rounded-xl p-10 text-center theme-card">
|
||||
<Boxes
|
||||
className="w-8 h-8 mx-auto mb-3"
|
||||
style={{ color: "var(--text-4)" }}
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<Muted>No workspaces found.</Muted>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
133
packages/web/src/components/layout/Sidebar.tsx
Normal file
133
packages/web/src/components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import { useTheme } from "@/hooks/useTheme";
|
||||
import { loadConfig } from "@/lib/config";
|
||||
import { COLOR } from "@/lib/constants";
|
||||
import { Link, useMatchRoute } from "@tanstack/react-router";
|
||||
import { motion } from "framer-motion";
|
||||
import { Boxes, Brain, ChevronRight, LayoutDashboard, Moon, Settings, Sun } from "lucide-react";
|
||||
|
||||
const navItems = [
|
||||
{ to: "/" as const, label: "Dashboard", icon: LayoutDashboard, exact: true },
|
||||
{ to: "/workspaces" as const, label: "Workspaces", icon: Boxes, exact: false },
|
||||
{ to: "/settings" as const, label: "Settings", icon: Settings, exact: false },
|
||||
];
|
||||
|
||||
export function Sidebar() {
|
||||
const matchRoute = useMatchRoute();
|
||||
const config = loadConfig();
|
||||
const { theme, toggle } = useTheme();
|
||||
|
||||
return (
|
||||
<motion.aside
|
||||
initial={{ x: -20, opacity: 0 }}
|
||||
animate={{ x: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||
className="w-14 sm:w-56 shrink-0 flex flex-col h-full"
|
||||
style={{
|
||||
background: "var(--sidebar-bg)",
|
||||
borderRight: "1px solid var(--border)",
|
||||
position: "relative",
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
{/* Logo */}
|
||||
<div className="px-3 sm:px-5 py-5" style={{ borderBottom: "1px solid var(--border)" }}>
|
||||
<div className="flex items-center gap-2.5 justify-center sm:justify-start">
|
||||
<div
|
||||
className="w-7 h-7 rounded-lg flex items-center justify-center shrink-0"
|
||||
style={{
|
||||
background: "linear-gradient(135deg, #4f46e5, #7c3aed)",
|
||||
boxShadow: `0 0 16px ${COLOR.accentGlow}`,
|
||||
}}
|
||||
>
|
||||
<Brain className="w-4 h-4 text-white" strokeWidth={2} />
|
||||
</div>
|
||||
<div className="hidden sm:block">
|
||||
<span
|
||||
className="font-semibold text-sm tracking-tight"
|
||||
style={{ color: "var(--text-1)" }}
|
||||
>
|
||||
Honcho UI
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{config && (
|
||||
<p
|
||||
className="text-xs mt-2 truncate font-mono hidden sm:block"
|
||||
style={{ color: "var(--text-4)" }}
|
||||
title={config.baseUrl}
|
||||
>
|
||||
{config.baseUrl.replace(/^https?:\/\//, "")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Nav */}
|
||||
<nav className="flex-1 px-2 sm:px-3 py-3 space-y-0.5">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = matchRoute({ to: item.to, fuzzy: !item.exact });
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className="relative flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm transition-all group justify-center sm:justify-start"
|
||||
style={{
|
||||
color: isActive ? "var(--accent-text)" : "var(--text-2)",
|
||||
background: isActive ? "var(--accent-dim)" : "transparent",
|
||||
}}
|
||||
title={item.label}
|
||||
>
|
||||
{isActive && (
|
||||
<motion.div
|
||||
layoutId="nav-indicator"
|
||||
className="absolute inset-0 rounded-lg"
|
||||
style={{
|
||||
background: "var(--accent-dim)",
|
||||
border: "1px solid var(--accent-border)",
|
||||
}}
|
||||
transition={{ type: "spring", bounce: 0.2, duration: 0.4 }}
|
||||
/>
|
||||
)}
|
||||
<Icon className="w-4 h-4 shrink-0 relative z-10" strokeWidth={isActive ? 2 : 1.5} />
|
||||
<span className="relative z-10 font-medium hidden sm:block">{item.label}</span>
|
||||
{isActive && (
|
||||
<ChevronRight
|
||||
className="w-3 h-3 ml-auto relative z-10 opacity-60 hidden sm:block"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Theme toggle + footer */}
|
||||
<div
|
||||
className="px-3 sm:px-5 py-3 flex items-center justify-between"
|
||||
style={{ borderTop: "1px solid var(--border)" }}
|
||||
>
|
||||
<p className="text-xs font-mono hidden sm:block" style={{ color: "var(--text-4)" }}>
|
||||
API v3
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
className="w-7 h-7 rounded-md flex items-center justify-center transition-colors mx-auto sm:mx-0"
|
||||
style={{
|
||||
background: "var(--surface)",
|
||||
border: "1px solid var(--border)",
|
||||
color: "var(--text-3)",
|
||||
}}
|
||||
title={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
|
||||
>
|
||||
{theme === "dark" ? (
|
||||
<Sun className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
) : (
|
||||
<Moon className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</motion.aside>
|
||||
);
|
||||
}
|
||||
423
packages/web/src/components/peers/PeerDetail.tsx
Normal file
423
packages/web/src/components/peers/PeerDetail.tsx
Normal file
@@ -0,0 +1,423 @@
|
||||
import {
|
||||
usePeer,
|
||||
usePeerCard,
|
||||
usePeerContext,
|
||||
usePeerRepresentation,
|
||||
useSearchPeer,
|
||||
useSetPeerCard,
|
||||
} from "@/api/queries";
|
||||
import { Badge } from "@/components/shared/Badge";
|
||||
import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
||||
import { JsonViewer } from "@/components/shared/JsonViewer";
|
||||
import { PageLoader } from "@/components/shared/LoadingSpinner";
|
||||
import { MarkdownRenderer } from "@/components/shared/MarkdownRenderer";
|
||||
import { PeerCardViewer } from "@/components/shared/PeerCardViewer";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input, Textarea } from "@/components/ui/input";
|
||||
import {
|
||||
Body,
|
||||
Caption,
|
||||
MonoCaption,
|
||||
Muted,
|
||||
PageTitle,
|
||||
SectionHeading,
|
||||
} from "@/components/ui/typography";
|
||||
import { COLOR } from "@/lib/constants";
|
||||
import { Link, useNavigate, useParams } from "@tanstack/react-router";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import {
|
||||
ChevronDown,
|
||||
Eye,
|
||||
EyeOff,
|
||||
MessageCircle,
|
||||
Save,
|
||||
Search,
|
||||
User,
|
||||
Users,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
export function PeerDetail() {
|
||||
const { workspaceId, peerId } = useParams({ strict: false }) as {
|
||||
workspaceId: string;
|
||||
peerId: string;
|
||||
};
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data: peer, isLoading, error } = usePeer(workspaceId, peerId);
|
||||
const { data: card, isLoading: cardLoading } = usePeerCard(workspaceId, peerId);
|
||||
const { data: context, isLoading: contextLoading } = usePeerContext(workspaceId, peerId);
|
||||
|
||||
const [repTarget, setRepTarget] = useState("");
|
||||
const [repTargetInput, setRepTargetInput] = useState("");
|
||||
const { data: representation, isLoading: repLoading } = usePeerRepresentation(
|
||||
workspaceId,
|
||||
peerId,
|
||||
repTarget || undefined,
|
||||
);
|
||||
|
||||
const setPeerCard = useSetPeerCard(workspaceId, peerId);
|
||||
const searchPeer = useSearchPeer(workspaceId, peerId);
|
||||
|
||||
const [cardDraft, setCardDraft] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [metaExpanded, setMetaExpanded] = useState(false);
|
||||
|
||||
const observeMe = (peer as { configuration?: { observe_me?: boolean } } | undefined)
|
||||
?.configuration?.observe_me;
|
||||
|
||||
const cardLines: string[] = Array.isArray((card as { peer_card?: unknown })?.peer_card)
|
||||
? (card as { peer_card: string[] }).peer_card
|
||||
: typeof card === "string"
|
||||
? [card]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="page-container page-container--xl">
|
||||
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }}>
|
||||
<div className="flex items-center gap-2 text-xs mb-4" style={{ color: "var(--text-3)" }}>
|
||||
<Link to="/workspaces" className="hover:underline">
|
||||
Workspaces
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<Link
|
||||
to="/workspaces/$workspaceId"
|
||||
params={{ workspaceId } as never}
|
||||
className="hover:underline font-mono"
|
||||
>
|
||||
{workspaceId}
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<Link
|
||||
to="/workspaces/$workspaceId/peers"
|
||||
params={{ workspaceId } as never}
|
||||
className="hover:underline"
|
||||
>
|
||||
Peers
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<User className="w-5 h-5" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
|
||||
<PageTitle className="font-mono break-all">{peerId}</PageTitle>
|
||||
{observeMe !== undefined && (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full font-mono"
|
||||
style={{
|
||||
background: observeMe ? COLOR.accentSubtle : COLOR.cardBaseBg,
|
||||
color: observeMe ? COLOR.accentText : COLOR.dimText,
|
||||
border: `1px solid ${observeMe ? COLOR.accentBorder : COLOR.cardBaseBorder}`,
|
||||
}}
|
||||
>
|
||||
{observeMe ? (
|
||||
<Eye className="w-3 h-3" strokeWidth={2} />
|
||||
) : (
|
||||
<EyeOff className="w-3 h-3" strokeWidth={2} />
|
||||
)}
|
||||
{observeMe ? "observed" : "not observed"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Body className="leading-none">Peer identity & memory</Body>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() =>
|
||||
navigate({
|
||||
to: "/workspaces/$workspaceId/peers/$peerId/chat",
|
||||
params: { workspaceId, peerId } as never,
|
||||
})
|
||||
}
|
||||
className="shrink-0 rounded-xl"
|
||||
>
|
||||
<MessageCircle className="w-4 h-4" strokeWidth={1.5} />
|
||||
Chat
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<div className="mt-6 space-y-4">
|
||||
<ErrorAlert error={error instanceof Error ? error : null} />
|
||||
{isLoading && <PageLoader />}
|
||||
|
||||
{!isLoading && peer && (
|
||||
<>
|
||||
{/* Search — prominent, always visible */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.05 }}
|
||||
className="rounded-xl p-5 theme-card"
|
||||
>
|
||||
<SectionHeading className="flex items-center gap-1.5 mb-3">
|
||||
<Search className="w-3.5 h-3.5" strokeWidth={2} />
|
||||
Search peer messages
|
||||
</SectionHeading>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (searchQuery.trim()) searchPeer.mutate(searchQuery.trim());
|
||||
}}
|
||||
className="flex gap-2 mb-4"
|
||||
>
|
||||
<Input
|
||||
autoFocus
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Semantic search across this peer's messages…"
|
||||
className="flex-1 text-sm"
|
||||
/>
|
||||
<Button type="submit" variant="accent" disabled={searchPeer.isPending}>
|
||||
{searchPeer.isPending ? "…" : "Search"}
|
||||
</Button>
|
||||
</form>
|
||||
<AnimatePresence>
|
||||
{searchPeer.data && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="space-y-3 overflow-hidden"
|
||||
>
|
||||
{(
|
||||
searchPeer.data as Array<{
|
||||
id: string;
|
||||
content: string;
|
||||
peer_id?: string;
|
||||
created_at?: string;
|
||||
}>
|
||||
).length === 0 ? (
|
||||
<Muted>No results.</Muted>
|
||||
) : (
|
||||
(
|
||||
searchPeer.data as Array<{
|
||||
id: string;
|
||||
content: string;
|
||||
peer_id?: string;
|
||||
created_at?: string;
|
||||
}>
|
||||
).map((r) => (
|
||||
<div
|
||||
key={r.id}
|
||||
className="py-3 px-4 rounded-lg"
|
||||
style={{
|
||||
background: "var(--surface)",
|
||||
border: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<Badge variant="blue">{r.peer_id ?? peerId}</Badge>
|
||||
{r.created_at && (
|
||||
<Caption>{new Date(r.created_at).toLocaleString()}</Caption>
|
||||
)}
|
||||
</div>
|
||||
<Body className="whitespace-pre-wrap">{r.content}</Body>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
|
||||
{/* Card + Representation — side by side */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Peer Card */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="rounded-xl p-5 theme-card"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<SectionHeading className="mb-0">Peer Card</SectionHeading>
|
||||
{!cardLoading &&
|
||||
(cardDraft === null ? (
|
||||
<Button
|
||||
variant="accent"
|
||||
size="sm"
|
||||
onClick={() => setCardDraft(cardLines.join("\n"))}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex gap-1.5">
|
||||
<Button
|
||||
variant="accent"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setPeerCard.mutate(cardDraft.split("\n").filter(Boolean));
|
||||
setCardDraft(null);
|
||||
}}
|
||||
disabled={setPeerCard.isPending}
|
||||
>
|
||||
<Save className="w-3 h-3" strokeWidth={2} />
|
||||
Save
|
||||
</Button>
|
||||
<Button variant="surface" size="sm" onClick={() => setCardDraft(null)}>
|
||||
<X className="w-3 h-3" strokeWidth={2} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{cardLoading ? (
|
||||
<PageLoader />
|
||||
) : (
|
||||
<AnimatePresence mode="wait">
|
||||
{cardDraft !== null ? (
|
||||
<motion.div key="edit" initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
|
||||
<Textarea
|
||||
value={cardDraft}
|
||||
onChange={(e) => setCardDraft(e.target.value)}
|
||||
rows={8}
|
||||
className="font-mono resize-y"
|
||||
style={{ minHeight: "8rem" }}
|
||||
/>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div key="view" initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
|
||||
{cardLines.length > 0 ? (
|
||||
<PeerCardViewer lines={cardLines} />
|
||||
) : (
|
||||
<Muted>No card data yet.</Muted>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* Representation */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.15 }}
|
||||
className="rounded-xl p-5 theme-card"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3 gap-3">
|
||||
<SectionHeading className="mb-0 flex items-center gap-1.5">
|
||||
<Users className="w-3.5 h-3.5" strokeWidth={2} />
|
||||
{repTarget ? (
|
||||
<>
|
||||
<MonoCaption as="span">{peerId}</MonoCaption>
|
||||
<span className="opacity-50">→</span>
|
||||
<MonoCaption as="span">{repTarget}</MonoCaption>
|
||||
</>
|
||||
) : (
|
||||
"Memory Representation"
|
||||
)}
|
||||
</SectionHeading>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
setRepTarget(repTargetInput.trim());
|
||||
}}
|
||||
className="flex items-center gap-1.5 shrink-0"
|
||||
>
|
||||
<Input
|
||||
value={repTargetInput}
|
||||
onChange={(e) => setRepTargetInput(e.target.value)}
|
||||
placeholder="view as peer…"
|
||||
className="text-xs font-mono h-7 w-36 rounded-lg"
|
||||
/>
|
||||
<Button type="submit" variant="surface" size="sm" className="h-7 px-2 text-xs">
|
||||
{repTarget ? "Update" : "Scope"}
|
||||
</Button>
|
||||
{repTarget && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => {
|
||||
setRepTarget("");
|
||||
setRepTargetInput("");
|
||||
}}
|
||||
>
|
||||
<X className="w-3 h-3" strokeWidth={2} />
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
{repLoading ? (
|
||||
<PageLoader />
|
||||
) : representation &&
|
||||
typeof (representation as { representation?: unknown }).representation ===
|
||||
"string" ? (
|
||||
<MarkdownRenderer
|
||||
content={(representation as { representation: string }).representation}
|
||||
workspaceId={workspaceId}
|
||||
/>
|
||||
) : (
|
||||
<JsonViewer data={representation} maxHeight="320px" />
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Context — full width */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="rounded-xl p-5 theme-card"
|
||||
>
|
||||
<SectionHeading>Peer Context</SectionHeading>
|
||||
{contextLoading ? (
|
||||
<PageLoader />
|
||||
) : typeof context === "string" ? (
|
||||
<Body className="whitespace-pre-wrap">{context}</Body>
|
||||
) : (
|
||||
<JsonViewer data={context} />
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* Metadata — collapsible */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.25 }}
|
||||
className="rounded-xl theme-card overflow-hidden"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMetaExpanded((v) => !v)}
|
||||
className="w-full flex items-center justify-between px-5 py-4"
|
||||
style={{ color: "var(--text-3)" }}
|
||||
>
|
||||
<SectionHeading className="mb-0">Metadata</SectionHeading>
|
||||
<motion.div
|
||||
animate={{ rotate: metaExpanded ? 0 : -90 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<ChevronDown
|
||||
className="w-4 h-4"
|
||||
strokeWidth={2}
|
||||
style={{ color: COLOR.dimText }}
|
||||
/>
|
||||
</motion.div>
|
||||
</button>
|
||||
<AnimatePresence initial={false}>
|
||||
{metaExpanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="px-5 pb-5">
|
||||
<JsonViewer data={peer.metadata} maxHeight="300px" />
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
329
packages/web/src/components/peers/PeerList.tsx
Normal file
329
packages/web/src/components/peers/PeerList.tsx
Normal file
@@ -0,0 +1,329 @@
|
||||
import { usePeers } from "@/api/queries";
|
||||
import type { components } from "@/api/schema.d.ts";
|
||||
import { EmptyState } from "@/components/shared/EmptyState";
|
||||
import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
||||
import { JsonViewer } from "@/components/shared/JsonViewer";
|
||||
import { PageLoader } from "@/components/shared/LoadingSpinner";
|
||||
import { Pagination } from "@/components/shared/Pagination";
|
||||
import { SortControl, type SortDir } from "@/components/shared/SortControl";
|
||||
import { MonoCaption, PageTitle } from "@/components/ui/typography";
|
||||
import { COLOR } from "@/lib/constants";
|
||||
import { Link, useNavigate, useParams } from "@tanstack/react-router";
|
||||
import { type Variants, motion } from "framer-motion";
|
||||
import { ArrowLeft, ChevronRight, Clock, Eye, Users } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
type Peer = components["schemas"]["Peer"];
|
||||
|
||||
type KindStyle = { bg: string; text: string; border: string };
|
||||
|
||||
const KIND_STYLES: Record<string, KindStyle> = {
|
||||
agent: { bg: COLOR.warningDim, text: COLOR.warning, border: COLOR.warningBorder },
|
||||
discord: { bg: "rgba(14,165,233,0.08)", text: "#38bdf8", border: "rgba(14,165,233,0.2)" },
|
||||
ai: { bg: COLOR.accentDim, text: COLOR.accentText, border: COLOR.accentBorder },
|
||||
};
|
||||
|
||||
function peerKind(id: string): (KindStyle & { label: string }) | null {
|
||||
if (id.startsWith("agent-")) return { label: "agent", ...KIND_STYLES.agent };
|
||||
if (id.startsWith("discord-")) return { label: "discord", ...KIND_STYLES.discord };
|
||||
if (["claude", "hermes", "codex"].includes(id)) return { label: "ai", ...KIND_STYLES.ai };
|
||||
return null;
|
||||
}
|
||||
|
||||
const SORT_OPTIONS = [
|
||||
{ value: "created_at", label: "Newest" },
|
||||
{ value: "id", label: "ID" },
|
||||
];
|
||||
|
||||
const container: Variants = {
|
||||
hidden: { opacity: 0 },
|
||||
show: { opacity: 1, transition: { staggerChildren: 0.06 } },
|
||||
};
|
||||
const item: Variants = {
|
||||
hidden: { opacity: 0, y: 10 },
|
||||
show: { opacity: 1, y: 0, transition: { type: "spring", stiffness: 300, damping: 25 } },
|
||||
};
|
||||
|
||||
export function PeerList() {
|
||||
const { workspaceId } = useParams({ strict: false }) as { workspaceId: string };
|
||||
const [page, setPage] = useState(1);
|
||||
const [sortField, setSortField] = useState("created_at");
|
||||
const [sortDir, setSortDir] = useState<SortDir>("desc");
|
||||
const [expandedMeta, setExpandedMeta] = useState<Set<string>>(new Set());
|
||||
const [activeFilters, setActiveFilters] = useState<Set<string>>(new Set());
|
||||
const navigate = useNavigate();
|
||||
const { data, isLoading, error } = usePeers(workspaceId, page);
|
||||
|
||||
const peers: Peer[] = (data as { items?: Peer[] } | undefined)?.items ?? [];
|
||||
const totalPages = (data as { pages?: number } | undefined)?.pages ?? 1;
|
||||
const total = (data as { total?: number } | undefined)?.total ?? 0;
|
||||
|
||||
const availableLabels = useMemo(() => {
|
||||
const labels = new Set<string>();
|
||||
for (const peer of peers) {
|
||||
const kind = peerKind(peer.id);
|
||||
if (kind) labels.add(kind.label);
|
||||
}
|
||||
return labels;
|
||||
}, [peers]);
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
return [...peers].sort((a, b) => {
|
||||
let cmp = 0;
|
||||
if (sortField === "created_at") {
|
||||
cmp = new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
|
||||
} else if (sortField === "id") {
|
||||
cmp = a.id.localeCompare(b.id);
|
||||
}
|
||||
return sortDir === "asc" ? cmp : -cmp;
|
||||
});
|
||||
}, [peers, sortField, sortDir]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (activeFilters.size === 0) return sorted;
|
||||
return sorted.filter((peer) => {
|
||||
const kind = peerKind(peer.id);
|
||||
return kind ? activeFilters.has(kind.label) : false;
|
||||
});
|
||||
}, [sorted, activeFilters]);
|
||||
|
||||
function toggleFilter(label: string) {
|
||||
setActiveFilters((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(label) ? next.delete(label) : next.add(label);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function handleSort(field: string, dir: SortDir) {
|
||||
setSortField(field);
|
||||
setSortDir(dir);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }} className="mb-6">
|
||||
<Link
|
||||
to="/workspaces/$workspaceId"
|
||||
params={{ workspaceId } as never}
|
||||
className="inline-flex items-center gap-1.5 text-xs mb-4 transition-colors"
|
||||
style={{ color: COLOR.dimText }}
|
||||
>
|
||||
<ArrowLeft className="w-3 h-3" strokeWidth={1.5} />
|
||||
{workspaceId}
|
||||
</Link>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Users className="w-5 h-5" style={{ color: COLOR.accent }} strokeWidth={1.5} />
|
||||
<PageTitle>Peers</PageTitle>
|
||||
{total > 0 && (
|
||||
<span
|
||||
className="ml-1 text-xs font-mono px-2 py-0.5 rounded-full"
|
||||
style={{
|
||||
background: COLOR.accentSubtle,
|
||||
color: COLOR.accentText,
|
||||
border: `1px solid ${COLOR.accentBorder}`,
|
||||
}}
|
||||
>
|
||||
{total}
|
||||
</span>
|
||||
)}
|
||||
<div className="ml-auto">
|
||||
<SortControl
|
||||
options={SORT_OPTIONS}
|
||||
field={sortField}
|
||||
dir={sortDir}
|
||||
onChange={handleSort}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<MonoCaption className="mt-0.5" as="p">
|
||||
{workspaceId}
|
||||
</MonoCaption>
|
||||
</motion.div>
|
||||
|
||||
{availableLabels.size > 0 && (
|
||||
<div className="flex items-center gap-2 flex-wrap mb-4">
|
||||
{[...availableLabels].map((label) => {
|
||||
const style = KIND_STYLES[label];
|
||||
const active = activeFilters.has(label);
|
||||
return (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
onClick={() => toggleFilter(label)}
|
||||
className="text-xs font-mono px-2 py-1 rounded transition-opacity hover:opacity-90"
|
||||
style={{
|
||||
background: active ? style.bg : "transparent",
|
||||
color: active ? style.text : "var(--text-4)",
|
||||
border: `1px solid ${active ? style.border : "var(--border)"}`,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{activeFilters.size > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveFilters(new Set())}
|
||||
className="text-xs font-mono px-2 py-1 rounded transition-opacity hover:opacity-80"
|
||||
style={{ color: "var(--text-4)" }}
|
||||
>
|
||||
clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ErrorAlert error={error instanceof Error ? error : null} />
|
||||
{isLoading && <PageLoader />}
|
||||
|
||||
{!isLoading && peers.length === 0 && (
|
||||
<EmptyState
|
||||
icon={Users}
|
||||
title="No peers found"
|
||||
description="No peers exist in this workspace."
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isLoading && peers.length > 0 && filtered.length === 0 && (
|
||||
<EmptyState
|
||||
icon={Users}
|
||||
title="No peers match"
|
||||
description="No peers match the selected filters."
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isLoading && filtered.length > 0 && (
|
||||
<>
|
||||
<motion.div
|
||||
variants={container}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
className="grid grid-cols-1 sm:grid-cols-2 gap-2"
|
||||
>
|
||||
{filtered.map((peer) => {
|
||||
const kind = peerKind(peer.id);
|
||||
const metaKeys = Object.keys(peer.metadata ?? {});
|
||||
const hasMeta = metaKeys.length > 0;
|
||||
const metaOpen = expandedMeta.has(peer.id);
|
||||
|
||||
function toggleMeta(e: React.MouseEvent) {
|
||||
e.stopPropagation();
|
||||
setExpandedMeta((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(peer.id) ? next.delete(peer.id) : next.add(peer.id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={peer.id}
|
||||
variants={item}
|
||||
className="rounded-xl overflow-hidden group"
|
||||
style={{
|
||||
background: COLOR.cardBaseBg,
|
||||
border: `1px solid ${COLOR.cardBaseBorder}`,
|
||||
}}
|
||||
whileHover={{
|
||||
background: COLOR.accentDimHover,
|
||||
borderColor: COLOR.accentBorder,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
navigate({
|
||||
to: "/workspaces/$workspaceId/peers/$peerId",
|
||||
params: { workspaceId, peerId: peer.id } as never,
|
||||
})
|
||||
}
|
||||
className="text-left w-full px-5 py-4"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span
|
||||
className="font-mono text-sm font-medium truncate"
|
||||
style={{ color: COLOR.accentSoft }}
|
||||
>
|
||||
{peer.id}
|
||||
</span>
|
||||
<ChevronRight
|
||||
className="w-4 h-4 shrink-0 ml-2 opacity-30 group-hover:opacity-70 transition-opacity"
|
||||
style={{ color: COLOR.accent }}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{kind && (
|
||||
<span
|
||||
className="text-xs font-mono px-1.5 py-0.5 rounded"
|
||||
style={{
|
||||
background: kind.bg,
|
||||
color: kind.text,
|
||||
border: `1px solid ${kind.border}`,
|
||||
}}
|
||||
>
|
||||
{kind.label}
|
||||
</span>
|
||||
)}
|
||||
{(peer.configuration as { observe_me?: boolean } | null)?.observe_me && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Eye
|
||||
className="w-3 h-3"
|
||||
style={{ color: COLOR.accentText }}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<span className="text-xs" style={{ color: COLOR.accentText }}>
|
||||
observed
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{peer.created_at && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock
|
||||
className="w-3 h-3"
|
||||
style={{ color: COLOR.dimIcon }}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<MonoCaption>{new Date(peer.created_at).toLocaleString()}</MonoCaption>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{hasMeta && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleMeta}
|
||||
className="w-full flex items-center gap-1.5 px-5 py-1.5 text-xs font-mono transition-opacity hover:opacity-80"
|
||||
style={{
|
||||
borderTop: `1px solid ${COLOR.cardBaseBorder}`,
|
||||
color: COLOR.dimText,
|
||||
}}
|
||||
>
|
||||
<ChevronRight
|
||||
className="w-3 h-3 transition-transform duration-150"
|
||||
style={{ transform: metaOpen ? "rotate(90deg)" : "rotate(0deg)" }}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
{metaKeys.length} metadata key{metaKeys.length !== 1 ? "s" : ""}
|
||||
</button>
|
||||
{metaOpen && (
|
||||
<div className="px-4 pb-4">
|
||||
<JsonViewer data={peer.metadata} maxHeight="200px" />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
465
packages/web/src/components/sessions/SessionDetail.tsx
Normal file
465
packages/web/src/components/sessions/SessionDetail.tsx
Normal file
@@ -0,0 +1,465 @@
|
||||
import {
|
||||
useAddPeersToSession,
|
||||
useCloneSession,
|
||||
useDeleteSession,
|
||||
usePeers,
|
||||
useRemovePeersFromSession,
|
||||
useSearchSession,
|
||||
useSessionContext,
|
||||
useSessionMessages,
|
||||
useSessionPeers,
|
||||
useSessionSummaries,
|
||||
} from "@/api/queries";
|
||||
import type { components } from "@/api/schema.d.ts";
|
||||
import { Badge } from "@/components/shared/Badge";
|
||||
import { ConfirmDialog } from "@/components/shared/ConfirmDialog";
|
||||
import { JsonViewer } from "@/components/shared/JsonViewer";
|
||||
import { PageLoader } from "@/components/shared/LoadingSpinner";
|
||||
import { Pagination } from "@/components/shared/Pagination";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Body,
|
||||
Caption,
|
||||
MonoCaption,
|
||||
Muted,
|
||||
PageTitle,
|
||||
SectionHeading,
|
||||
} from "@/components/ui/typography";
|
||||
import { Link, useNavigate, useParams } from "@tanstack/react-router";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { AlignLeft, Clock, Copy, MessageSquare, Search, Trash2, Users, X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
type Message = components["schemas"]["Message"];
|
||||
type SessionSummaries = components["schemas"]["SessionSummaries"];
|
||||
type Summary = components["schemas"]["Summary"];
|
||||
type Tab = "messages" | "summaries" | "context" | "peers";
|
||||
|
||||
export function SessionDetail() {
|
||||
const { workspaceId, sessionId } = useParams({ strict: false }) as {
|
||||
workspaceId: string;
|
||||
sessionId: string;
|
||||
};
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [tab, setTab] = useState<Tab>("messages");
|
||||
const [page, setPage] = useState(1);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [searchActive, setSearchActive] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
|
||||
const { data: msgData, isLoading: msgsLoading } = useSessionMessages(
|
||||
workspaceId,
|
||||
sessionId,
|
||||
page,
|
||||
);
|
||||
const { data: summaries, isLoading: summariesLoading } = useSessionSummaries(
|
||||
workspaceId,
|
||||
sessionId,
|
||||
);
|
||||
const { data: context, isLoading: contextLoading } = useSessionContext(workspaceId, sessionId);
|
||||
const { data: sessionPeers, isLoading: peersLoading } = useSessionPeers(workspaceId, sessionId);
|
||||
const { data: allPeers } = usePeers(workspaceId, 1, 100);
|
||||
|
||||
const deleteSession = useDeleteSession(workspaceId);
|
||||
const cloneSession = useCloneSession(workspaceId);
|
||||
const searchSession = useSearchSession(workspaceId, sessionId);
|
||||
const removePeers = useRemovePeersFromSession(workspaceId, sessionId);
|
||||
const addPeers = useAddPeersToSession(workspaceId, sessionId);
|
||||
|
||||
const messages: Message[] = (msgData as { items?: Message[] } | undefined)?.items ?? [];
|
||||
const totalPages = (msgData as { pages?: number } | undefined)?.pages ?? 1;
|
||||
|
||||
const sessionPeerItems =
|
||||
(sessionPeers as { items?: Array<{ id?: string; peer_id?: string }> } | undefined)?.items ?? [];
|
||||
|
||||
const memberPeerIds = new Set(sessionPeerItems.map((p) => p.id ?? p.peer_id ?? ""));
|
||||
|
||||
const availablePeers = (
|
||||
(allPeers as { items?: Array<{ id: string }> } | undefined)?.items ?? []
|
||||
).filter((p) => !memberPeerIds.has(p.id));
|
||||
|
||||
const tabs: Array<{ id: Tab; label: string }> = [
|
||||
{ id: "messages", label: "Messages" },
|
||||
{ id: "summaries", label: "Summaries" },
|
||||
{ id: "context", label: "Context" },
|
||||
{ id: "peers", label: "Peers" },
|
||||
];
|
||||
|
||||
const handleDelete = async () => {
|
||||
await deleteSession.mutateAsync(sessionId);
|
||||
navigate({
|
||||
to: "/workspaces/$workspaceId/sessions" as never,
|
||||
params: { workspaceId } as never,
|
||||
});
|
||||
};
|
||||
|
||||
const handleClone = async () => {
|
||||
const cloned = await cloneSession.mutateAsync(sessionId);
|
||||
if ((cloned as { id?: string })?.id) {
|
||||
navigate({
|
||||
to: "/workspaces/$workspaceId/sessions/$sessionId" as never,
|
||||
params: { workspaceId, sessionId: (cloned as { id: string }).id } as never,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-container page-container--wide">
|
||||
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }}>
|
||||
<div className="flex items-center gap-2 text-xs mb-4" style={{ color: "var(--text-3)" }}>
|
||||
<Link
|
||||
to="/workspaces/$workspaceId"
|
||||
params={{ workspaceId } as never}
|
||||
className="hover:underline font-mono"
|
||||
>
|
||||
{workspaceId}
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<Link
|
||||
to="/workspaces/$workspaceId/sessions"
|
||||
params={{ workspaceId } as never}
|
||||
className="hover:underline"
|
||||
>
|
||||
Sessions
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between gap-4 mb-1">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<MessageSquare
|
||||
className="w-5 h-5 flex-shrink-0"
|
||||
style={{ color: "var(--accent)" }}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<PageTitle className="font-mono break-all">{sessionId}</PageTitle>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<Button
|
||||
variant={searchActive ? "accent" : "surface"}
|
||||
size="icon"
|
||||
onClick={() => setSearchActive((v) => !v)}
|
||||
aria-label="Search session"
|
||||
>
|
||||
<Search className="w-3.5 h-3.5" strokeWidth={2} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="surface"
|
||||
size="icon"
|
||||
onClick={handleClone}
|
||||
disabled={cloneSession.isPending}
|
||||
aria-label="Clone session"
|
||||
>
|
||||
<Copy className="w-3.5 h-3.5" strokeWidth={2} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
aria-label="Delete session"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" strokeWidth={2} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Body className="leading-none">Session detail</Body>
|
||||
</motion.div>
|
||||
|
||||
{/* Inline search bar */}
|
||||
<AnimatePresence>
|
||||
{searchActive && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0, marginTop: 0 }}
|
||||
animate={{ opacity: 1, height: "auto", marginTop: 16 }}
|
||||
exit={{ opacity: 0, height: 0, marginTop: 0 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (searchQuery.trim()) searchSession.mutate(searchQuery.trim());
|
||||
}}
|
||||
className="flex gap-2"
|
||||
>
|
||||
<Input
|
||||
autoFocus
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search within this session…"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button type="submit" variant="accent" disabled={searchSession.isPending}>
|
||||
{searchSession.isPending ? "…" : "Search"}
|
||||
</Button>
|
||||
</form>
|
||||
{searchSession.data && (
|
||||
<div className="mt-3 rounded-xl p-4 theme-card space-y-2">
|
||||
{(searchSession.data as Array<{ id: string; content: string; peer_id?: string }>)
|
||||
.length === 0 ? (
|
||||
<Muted>No results.</Muted>
|
||||
) : (
|
||||
(
|
||||
searchSession.data as Array<{ id: string; content: string; peer_id?: string }>
|
||||
).map((r) => (
|
||||
<div
|
||||
key={r.id}
|
||||
className="text-sm py-2"
|
||||
style={{ borderBottom: "1px solid var(--border)", color: "var(--text-2)" }}
|
||||
>
|
||||
{r.peer_id && <Badge variant="blue">{r.peer_id}</Badge>}
|
||||
<p className="mt-1 whitespace-pre-wrap">{r.content}</p>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="mt-8">
|
||||
{/* Tab bar */}
|
||||
<div
|
||||
className="flex gap-0.5 mb-4 p-1 rounded-xl"
|
||||
style={{ background: "var(--bg-3)", border: "1px solid var(--border)" }}
|
||||
>
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
onClick={() => setTab(t.id)}
|
||||
className="relative flex-1 px-3 py-1.5 rounded-lg text-sm font-medium transition-all"
|
||||
style={{ color: tab === t.id ? "var(--text-1)" : "var(--text-3)" }}
|
||||
>
|
||||
{tab === t.id && (
|
||||
<motion.div
|
||||
layoutId="session-tab-active"
|
||||
className="absolute inset-0 rounded-lg"
|
||||
style={{ background: "var(--bg-2)", border: "1px solid var(--border)" }}
|
||||
transition={{ type: "spring", bounce: 0.2, duration: 0.35 }}
|
||||
/>
|
||||
)}
|
||||
<span className="relative z-10">{t.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
key={tab}
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="rounded-xl p-5 theme-card"
|
||||
>
|
||||
{tab === "messages" &&
|
||||
(msgsLoading ? (
|
||||
<PageLoader />
|
||||
) : (
|
||||
<div>
|
||||
{messages.length === 0 ? (
|
||||
<Muted>No messages.</Muted>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{messages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className="pb-4"
|
||||
style={{ borderBottom: "1px solid var(--border)" }}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<Badge variant={msg.peer_id ? "blue" : "default"}>
|
||||
{msg.peer_id ?? "system"}
|
||||
</Badge>
|
||||
{msg.token_count != null && <Caption>{msg.token_count} tokens</Caption>}
|
||||
{msg.created_at && (
|
||||
<Caption>{new Date(msg.created_at).toLocaleString()}</Caption>
|
||||
)}
|
||||
</div>
|
||||
<Body className="whitespace-pre-wrap">{msg.content}</Body>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
|
||||
</div>
|
||||
))}
|
||||
|
||||
{tab === "summaries" &&
|
||||
(summariesLoading ? <PageLoader /> : <SummariesDisplay summaries={summaries} />)}
|
||||
|
||||
{tab === "context" &&
|
||||
(contextLoading ? (
|
||||
<PageLoader />
|
||||
) : (
|
||||
<>
|
||||
<SectionHeading>Session Context</SectionHeading>
|
||||
{typeof context === "string" ? (
|
||||
<Body className="whitespace-pre-wrap">{context}</Body>
|
||||
) : (
|
||||
<JsonViewer data={context} maxHeight="500px" />
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
|
||||
{tab === "peers" &&
|
||||
(peersLoading ? (
|
||||
<PageLoader />
|
||||
) : (
|
||||
<SessionPeersTab
|
||||
members={sessionPeerItems}
|
||||
available={availablePeers}
|
||||
onRemove={(id) => removePeers.mutate([id])}
|
||||
onAdd={(id) => addPeers.mutate({ [id]: {} })}
|
||||
removing={removePeers.isPending}
|
||||
adding={addPeers.isPending}
|
||||
/>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmDelete}
|
||||
title="Delete session"
|
||||
description={`Permanently delete session "${sessionId}"? This cannot be undone.`}
|
||||
confirmLabel="Delete session"
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => setConfirmDelete(false)}
|
||||
loading={deleteSession.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionPeersTab({
|
||||
members,
|
||||
available,
|
||||
onRemove,
|
||||
onAdd,
|
||||
removing,
|
||||
adding,
|
||||
}: {
|
||||
members: Array<{ id?: string; peer_id?: string }> | undefined;
|
||||
available: Array<{ id: string }>;
|
||||
onRemove: (id: string) => void;
|
||||
onAdd: (id: string) => void;
|
||||
removing: boolean;
|
||||
adding: boolean;
|
||||
}) {
|
||||
const list = members ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<SectionHeading className="mb-2">
|
||||
<Users className="w-3.5 h-3.5 inline mr-1.5" strokeWidth={2} />
|
||||
Session members ({list.length})
|
||||
</SectionHeading>
|
||||
{list.length === 0 ? (
|
||||
<Muted>No peers in this session.</Muted>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{list.map((p) => {
|
||||
const id = p.id ?? p.peer_id ?? "";
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
className="flex items-center justify-between py-1.5 px-3 rounded-lg"
|
||||
style={{ background: "var(--surface)", border: "1px solid var(--border)" }}
|
||||
>
|
||||
<span className="text-xs font-mono" style={{ color: "var(--accent-text)" }}>
|
||||
{id}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onRemove(id)}
|
||||
disabled={removing}
|
||||
aria-label={`Remove ${id}`}
|
||||
>
|
||||
<X className="w-3 h-3" strokeWidth={2} />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{available.length > 0 && (
|
||||
<div>
|
||||
<SectionHeading className="mb-2">Add peer</SectionHeading>
|
||||
<div className="space-y-1 max-h-48 overflow-auto">
|
||||
{available.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => onAdd(p.id)}
|
||||
disabled={adding}
|
||||
className="w-full text-left py-1.5 px-3 rounded-lg text-xs font-mono transition-all disabled:opacity-40"
|
||||
style={{
|
||||
background: "var(--surface)",
|
||||
border: "1px solid var(--border)",
|
||||
color: "var(--text-3)",
|
||||
}}
|
||||
>
|
||||
+ {p.id}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryCard({ label, summary }: { label: string; summary: Summary }) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl p-4"
|
||||
style={{ background: "var(--surface)", border: "1px solid var(--border)" }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<AlignLeft className="w-3.5 h-3.5" style={{ color: "var(--text-4)" }} strokeWidth={1.5} />
|
||||
<span className="text-xs font-medium" style={{ color: "var(--text-2)" }}>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{summary.token_count != null && <MonoCaption>{summary.token_count} tok</MonoCaption>}
|
||||
{summary.created_at && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" style={{ color: "var(--text-4)" }} strokeWidth={1.5} />
|
||||
<MonoCaption>{new Date(summary.created_at).toLocaleString()}</MonoCaption>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Body className="whitespace-pre-wrap">{summary.content}</Body>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SummariesDisplay({ summaries }: { summaries: unknown }) {
|
||||
const data = summaries as SessionSummaries | null | undefined;
|
||||
|
||||
if (!data || (!data.short_summary && !data.long_summary)) {
|
||||
return (
|
||||
<>
|
||||
<SectionHeading>Session Summaries</SectionHeading>
|
||||
<Caption as="p">No summaries available yet.</Caption>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionHeading>Session Summaries</SectionHeading>
|
||||
<div className="space-y-3">
|
||||
{data.short_summary && <SummaryCard label="Short summary" summary={data.short_summary} />}
|
||||
{data.long_summary && <SummaryCard label="Long summary" summary={data.long_summary} />}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
204
packages/web/src/components/sessions/SessionList.tsx
Normal file
204
packages/web/src/components/sessions/SessionList.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
import { useSessions } from "@/api/queries";
|
||||
import type { components } from "@/api/schema.d.ts";
|
||||
import { EmptyState } from "@/components/shared/EmptyState";
|
||||
import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
||||
import { PageLoader } from "@/components/shared/LoadingSpinner";
|
||||
import { Pagination } from "@/components/shared/Pagination";
|
||||
import { SortControl, type SortDir } from "@/components/shared/SortControl";
|
||||
import { MonoCaption, PageTitle } from "@/components/ui/typography";
|
||||
import { COLOR } from "@/lib/constants";
|
||||
import { Link, useNavigate, useParams } from "@tanstack/react-router";
|
||||
import { type Variants, motion } from "framer-motion";
|
||||
import { ArrowLeft, ChevronRight, CircleDot, Clock, MessageSquare } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
type Session = components["schemas"]["Session"];
|
||||
|
||||
const SORT_OPTIONS = [
|
||||
{ value: "created_at", label: "Newest" },
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "id", label: "ID" },
|
||||
];
|
||||
|
||||
const container: Variants = {
|
||||
hidden: { opacity: 0 },
|
||||
show: { opacity: 1, transition: { staggerChildren: 0.05 } },
|
||||
};
|
||||
const item: Variants = {
|
||||
hidden: { opacity: 0, y: 10 },
|
||||
show: { opacity: 1, y: 0, transition: { type: "spring", stiffness: 280, damping: 24 } },
|
||||
};
|
||||
|
||||
export function SessionList() {
|
||||
const { workspaceId } = useParams({ strict: false }) as { workspaceId: string };
|
||||
const [page, setPage] = useState(1);
|
||||
const [sortField, setSortField] = useState("created_at");
|
||||
const [sortDir, setSortDir] = useState<SortDir>("desc");
|
||||
const navigate = useNavigate();
|
||||
const { data, isLoading, error } = useSessions(workspaceId, page);
|
||||
|
||||
const sessions: Session[] = (data as { items?: Session[] } | undefined)?.items ?? [];
|
||||
const totalPages = (data as { pages?: number } | undefined)?.pages ?? 1;
|
||||
const total = (data as { total?: number } | undefined)?.total ?? 0;
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
return [...sessions].sort((a, b) => {
|
||||
let cmp = 0;
|
||||
if (sortField === "created_at") {
|
||||
cmp = new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
|
||||
} else if (sortField === "active") {
|
||||
// active sessions first (true > false)
|
||||
cmp = Number(a.is_active) - Number(b.is_active);
|
||||
} else if (sortField === "id") {
|
||||
cmp = a.id.localeCompare(b.id);
|
||||
}
|
||||
return sortDir === "asc" ? cmp : -cmp;
|
||||
});
|
||||
}, [sessions, sortField, sortDir]);
|
||||
|
||||
function handleSort(field: string, dir: SortDir) {
|
||||
setSortField(field);
|
||||
setSortDir(dir);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }} className="mb-6">
|
||||
<Link
|
||||
to="/workspaces/$workspaceId"
|
||||
params={{ workspaceId } as never}
|
||||
className="inline-flex items-center gap-1.5 text-xs mb-4 transition-colors"
|
||||
style={{ color: COLOR.dimText }}
|
||||
>
|
||||
<ArrowLeft className="w-3 h-3" strokeWidth={1.5} />
|
||||
{workspaceId}
|
||||
</Link>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<MessageSquare className="w-5 h-5" style={{ color: COLOR.accent }} strokeWidth={1.5} />
|
||||
<PageTitle>Sessions</PageTitle>
|
||||
{total > 0 && (
|
||||
<span
|
||||
className="ml-1 text-xs font-mono px-2 py-0.5 rounded-full"
|
||||
style={{
|
||||
background: COLOR.accentSubtle,
|
||||
color: COLOR.accentText,
|
||||
border: `1px solid ${COLOR.accentBorder}`,
|
||||
}}
|
||||
>
|
||||
{total}
|
||||
</span>
|
||||
)}
|
||||
<div className="ml-auto">
|
||||
<SortControl
|
||||
options={SORT_OPTIONS}
|
||||
field={sortField}
|
||||
dir={sortDir}
|
||||
onChange={handleSort}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<MonoCaption className="mt-0.5" as="p">
|
||||
{workspaceId}
|
||||
</MonoCaption>
|
||||
</motion.div>
|
||||
|
||||
<ErrorAlert error={error instanceof Error ? error : null} />
|
||||
{isLoading && <PageLoader />}
|
||||
|
||||
{!isLoading && sessions.length === 0 && (
|
||||
<EmptyState
|
||||
icon={MessageSquare}
|
||||
title="No sessions found"
|
||||
description="No sessions exist in this workspace."
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isLoading && sorted.length > 0 && (
|
||||
<>
|
||||
<motion.div variants={container} initial="hidden" animate="show" className="space-y-2">
|
||||
{sorted.map((session) => (
|
||||
<motion.button
|
||||
key={session.id}
|
||||
variants={item}
|
||||
onClick={() =>
|
||||
navigate({
|
||||
to: "/workspaces/$workspaceId/sessions/$sessionId",
|
||||
params: { workspaceId, sessionId: session.id } as never,
|
||||
})
|
||||
}
|
||||
className="w-full text-left rounded-xl px-5 py-4 group"
|
||||
style={{
|
||||
background: COLOR.cardBaseBg,
|
||||
border: `1px solid ${COLOR.cardBaseBorder}`,
|
||||
}}
|
||||
whileHover={{
|
||||
background: COLOR.accentDimHover,
|
||||
borderColor: COLOR.accentBorder,
|
||||
x: 2,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span
|
||||
className="font-mono text-sm font-medium truncate"
|
||||
style={{ color: COLOR.accentSoft }}
|
||||
>
|
||||
{session.id}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 shrink-0 ml-2">
|
||||
{session.is_active && (
|
||||
<div className="flex items-center gap-1">
|
||||
<motion.div
|
||||
animate={{ opacity: [0.5, 1, 0.5] }}
|
||||
transition={{ duration: 2, repeat: Number.POSITIVE_INFINITY }}
|
||||
>
|
||||
<CircleDot
|
||||
className="w-3 h-3"
|
||||
style={{ color: COLOR.success }}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</motion.div>
|
||||
<span className="text-xs" style={{ color: COLOR.success }}>
|
||||
Active
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<ChevronRight
|
||||
className="w-4 h-4 opacity-30 group-hover:opacity-70 transition-opacity"
|
||||
style={{ color: COLOR.accent }}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
{session.created_at && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Clock
|
||||
className="w-3 h-3"
|
||||
style={{ color: COLOR.dimIcon }}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<MonoCaption>{new Date(session.created_at).toLocaleString()}</MonoCaption>
|
||||
</div>
|
||||
)}
|
||||
{(session.metadata as Record<string, string> | null)?.source && (
|
||||
<span
|
||||
className="text-xs font-mono px-1.5 py-0.5 rounded"
|
||||
style={{
|
||||
background: COLOR.accentDim,
|
||||
border: `1px solid ${COLOR.accentBorderStrong}`,
|
||||
color: COLOR.dimText,
|
||||
}}
|
||||
>
|
||||
{(session.metadata as Record<string, string>).source}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</motion.button>
|
||||
))}
|
||||
</motion.div>
|
||||
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
215
packages/web/src/components/settings/SettingsForm.tsx
Normal file
215
packages/web/src/components/settings/SettingsForm.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input, Textarea } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Muted } from "@/components/ui/typography";
|
||||
import {
|
||||
type Config,
|
||||
type HealthStatus,
|
||||
checkConnection,
|
||||
configSchema,
|
||||
loadConfig,
|
||||
saveConfig,
|
||||
} from "@/lib/config";
|
||||
import { COLOR } from "@/lib/constants";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { AlertCircle, CheckCircle, Loader, Lock, LockOpen, Wifi, WifiOff } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
interface SettingsFormProps {
|
||||
onSaved?: () => void;
|
||||
}
|
||||
|
||||
const statusConfig = {
|
||||
ok: { icon: CheckCircle, color: COLOR.success, label: "Connected" },
|
||||
"auth-required": { icon: AlertCircle, color: COLOR.warning, label: "Auth required" },
|
||||
unreachable: { icon: WifiOff, color: COLOR.destructive, label: "Unreachable" },
|
||||
checking: { icon: Loader, color: COLOR.accentText, label: "Checking..." },
|
||||
};
|
||||
|
||||
export function SettingsForm({ onSaved }: SettingsFormProps) {
|
||||
const existing = loadConfig();
|
||||
const [baseUrl, setBaseUrl] = useState(existing?.baseUrl ?? "http://localhost:8000");
|
||||
const [token, setToken] = useState(existing?.token ?? "");
|
||||
const [errors, setErrors] = useState<Partial<Record<keyof Config, string>>>({});
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [health, setHealth] = useState<{ status: HealthStatus; message: string } | null>(null);
|
||||
const [checking, setChecking] = useState(false);
|
||||
|
||||
async function handleTest() {
|
||||
setChecking(true);
|
||||
setHealth({ status: "checking", message: "Connecting..." });
|
||||
const result = await checkConnection(baseUrl, token || undefined);
|
||||
setHealth(result);
|
||||
setChecking(false);
|
||||
|
||||
if (result.status === "auth-required" && !token) {
|
||||
document.getElementById("honcho-token")?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.SyntheticEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
const result = configSchema.safeParse({ baseUrl, token });
|
||||
if (!result.success) {
|
||||
const fieldErrors: typeof errors = {};
|
||||
for (const issue of result.error.issues) {
|
||||
const key = issue.path[0] as keyof Config;
|
||||
fieldErrors[key] = issue.message;
|
||||
}
|
||||
setErrors(fieldErrors);
|
||||
return;
|
||||
}
|
||||
setErrors({});
|
||||
saveConfig(result.data);
|
||||
setSaved(true);
|
||||
setTimeout(() => {
|
||||
setSaved(false);
|
||||
onSaved?.();
|
||||
}, 600);
|
||||
}
|
||||
|
||||
const StatusIcon = health ? statusConfig[health.status].icon : null;
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="rounded-2xl p-6 space-y-5"
|
||||
style={{
|
||||
background: "var(--bg-2)",
|
||||
border: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
{/* Base URL */}
|
||||
<div>
|
||||
<Label className="mb-1.5 text-sm">Honcho Base URL</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="url"
|
||||
value={baseUrl}
|
||||
onChange={(e) => {
|
||||
setBaseUrl(e.target.value);
|
||||
setHealth(null);
|
||||
}}
|
||||
placeholder="http://localhost:8000"
|
||||
className="flex-1 font-mono rounded-xl"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="accent"
|
||||
onClick={handleTest}
|
||||
disabled={checking || !baseUrl}
|
||||
className="rounded-xl"
|
||||
>
|
||||
{checking ? (
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 1, repeat: Number.POSITIVE_INFINITY, ease: "linear" }}
|
||||
>
|
||||
<Loader className="w-4 h-4" strokeWidth={1.5} />
|
||||
</motion.div>
|
||||
) : (
|
||||
<Wifi className="w-4 h-4" strokeWidth={1.5} />
|
||||
)}
|
||||
<span className="hidden sm:block">Test</span>
|
||||
</Button>
|
||||
</div>
|
||||
{errors.baseUrl && (
|
||||
<p className="text-xs mt-1" style={{ color: COLOR.destructive }}>
|
||||
{errors.baseUrl}
|
||||
</p>
|
||||
)}
|
||||
<Muted className="text-xs mt-1.5">URL of your self-hosted Honcho instance</Muted>
|
||||
</div>
|
||||
|
||||
{/* Health status */}
|
||||
<AnimatePresence>
|
||||
{health && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div
|
||||
className="rounded-xl px-4 py-3 flex items-center gap-2.5"
|
||||
style={{
|
||||
background: "var(--surface)",
|
||||
border: `1px solid ${statusConfig[health.status].color}33`,
|
||||
}}
|
||||
>
|
||||
{StatusIcon && (
|
||||
<StatusIcon
|
||||
className="w-4 h-4 shrink-0"
|
||||
style={{ color: statusConfig[health.status].color }}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<p
|
||||
className="text-sm font-medium"
|
||||
style={{ color: statusConfig[health.status].color }}
|
||||
>
|
||||
{statusConfig[health.status].label}
|
||||
</p>
|
||||
<Muted className="text-xs mt-0.5">{health.message}</Muted>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Token */}
|
||||
<div>
|
||||
<Label htmlFor="honcho-token" className="flex items-center gap-1.5 mb-1.5 text-sm">
|
||||
{token ? (
|
||||
<Lock className="w-3.5 h-3.5" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
|
||||
) : (
|
||||
<LockOpen
|
||||
className="w-3.5 h-3.5"
|
||||
style={{ color: "var(--text-3)" }}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
)}
|
||||
API Token
|
||||
<span
|
||||
className="ml-1 text-xs font-normal px-1.5 py-0.5 rounded-full"
|
||||
style={{
|
||||
background: "var(--surface)",
|
||||
border: "1px solid var(--border)",
|
||||
color: "var(--text-3)",
|
||||
}}
|
||||
>
|
||||
optional
|
||||
</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="honcho-token"
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="eyJ... (required only if your instance has auth enabled)"
|
||||
className="font-mono rounded-xl"
|
||||
/>
|
||||
{health?.status === "auth-required" && !token && (
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="text-xs mt-1"
|
||||
style={{ color: COLOR.warning }}
|
||||
>
|
||||
This instance requires an API token to proceed
|
||||
</motion.p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
className="w-full py-2.5 px-4 rounded-xl"
|
||||
style={saved ? { background: "#059669" } : undefined}
|
||||
>
|
||||
{saved ? "✓ Saved" : "Save Connection"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
43
packages/web/src/components/shared/Badge.tsx
Normal file
43
packages/web/src/components/shared/Badge.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
interface BadgeProps {
|
||||
children: React.ReactNode;
|
||||
variant?: "default" | "green" | "yellow" | "red" | "blue";
|
||||
}
|
||||
|
||||
const variantStyles: Record<string, React.CSSProperties> = {
|
||||
default: {
|
||||
background: "var(--surface)",
|
||||
color: "var(--text-2)",
|
||||
border: "1px solid var(--border)",
|
||||
},
|
||||
green: {
|
||||
background: "rgba(52,211,153,0.08)",
|
||||
color: "#34d399",
|
||||
border: "1px solid rgba(52,211,153,0.2)",
|
||||
},
|
||||
yellow: {
|
||||
background: "rgba(245,158,11,0.08)",
|
||||
color: "#f59e0b",
|
||||
border: "1px solid rgba(245,158,11,0.2)",
|
||||
},
|
||||
red: {
|
||||
background: "rgba(239,68,68,0.08)",
|
||||
color: "#f87171",
|
||||
border: "1px solid rgba(239,68,68,0.2)",
|
||||
},
|
||||
blue: {
|
||||
background: "rgba(99,102,241,0.08)",
|
||||
color: "var(--accent-text)",
|
||||
border: "1px solid var(--accent-border)",
|
||||
},
|
||||
};
|
||||
|
||||
export function Badge({ children, variant = "default" }: BadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium font-mono"
|
||||
style={variantStyles[variant]}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
28
packages/web/src/components/shared/Card.tsx
Normal file
28
packages/web/src/components/shared/Card.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
interface CardProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function Card({ children, className = "", onClick }: CardProps) {
|
||||
return (
|
||||
<motion.div
|
||||
onClick={onClick}
|
||||
whileHover={onClick ? { scale: 1.005, y: -1 } : undefined}
|
||||
whileTap={onClick ? { scale: 0.998 } : undefined}
|
||||
className={className}
|
||||
style={{
|
||||
background: "rgba(255,255,255,0.02)",
|
||||
border: "1px solid rgba(255,255,255,0.06)",
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
cursor: onClick ? "pointer" : "default",
|
||||
transition: "border-color 0.2s",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
70
packages/web/src/components/shared/ConfirmDialog.tsx
Normal file
70
packages/web/src/components/shared/ConfirmDialog.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { COLOR } from "@/lib/constants";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
open: boolean;
|
||||
title: string;
|
||||
description: string;
|
||||
confirmLabel?: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
danger?: boolean;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
open,
|
||||
title,
|
||||
description,
|
||||
confirmLabel = "Confirm",
|
||||
onConfirm,
|
||||
onCancel,
|
||||
danger = true,
|
||||
loading = false,
|
||||
}: ConfirmDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onCancel()}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<div className="flex items-start gap-3 mb-4">
|
||||
{danger && (
|
||||
<div
|
||||
className="w-9 h-9 rounded-xl flex items-center justify-center flex-shrink-0 mt-0.5"
|
||||
style={{ background: COLOR.destructiveDim }}
|
||||
>
|
||||
<AlertTriangle
|
||||
className="w-4 h-4"
|
||||
style={{ color: COLOR.destructive }}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription className="mt-1">{description}</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="surface" size="sm" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant={danger ? "destructive" : "accent"}
|
||||
size="sm"
|
||||
onClick={onConfirm}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "..." : confirmLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
37
packages/web/src/components/shared/EmptyState.tsx
Normal file
37
packages/web/src/components/shared/EmptyState.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Body, Caption } from "@/components/ui/typography";
|
||||
import { COLOR } from "@/lib/constants";
|
||||
import { motion } from "framer-motion";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon?: LucideIcon;
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function EmptyState({ icon: Icon, title, description, action }: EmptyStateProps) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className="flex flex-col items-center justify-center py-20 text-center"
|
||||
>
|
||||
{Icon && (
|
||||
<div
|
||||
className="w-12 h-12 rounded-xl flex items-center justify-center mb-4"
|
||||
style={{
|
||||
background: COLOR.accentSubtle,
|
||||
border: `1px solid ${COLOR.accentBorderStrong}`,
|
||||
}}
|
||||
>
|
||||
<Icon className="w-5 h-5" style={{ color: COLOR.accentMuted }} strokeWidth={1.5} />
|
||||
</div>
|
||||
)}
|
||||
<Body className="font-medium">{title}</Body>
|
||||
{description && <Caption className="mt-1.5 max-w-xs leading-relaxed">{description}</Caption>}
|
||||
{action && <div className="mt-4">{action}</div>}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
26
packages/web/src/components/shared/ErrorAlert.tsx
Normal file
26
packages/web/src/components/shared/ErrorAlert.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { COLOR } from "@/lib/constants";
|
||||
|
||||
interface ErrorAlertProps {
|
||||
error: Error | null;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export function ErrorAlert({ error, message }: ErrorAlertProps) {
|
||||
if (!error) return null;
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl p-4 mb-4"
|
||||
style={{
|
||||
background: COLOR.destructiveDim,
|
||||
border: `1px solid ${COLOR.destructiveBorderStrong}`,
|
||||
}}
|
||||
>
|
||||
<p className="text-sm font-medium" style={{ color: COLOR.destructive }}>
|
||||
{message ?? "An error occurred"}
|
||||
</p>
|
||||
<p className="text-xs mt-1 font-mono" style={{ color: COLOR.destructiveMuted }}>
|
||||
{error.message}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
packages/web/src/components/shared/FormModal.tsx
Normal file
29
packages/web/src/components/shared/FormModal.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface FormModalProps {
|
||||
open: boolean;
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
maxWidth?: string;
|
||||
}
|
||||
|
||||
export function FormModal({
|
||||
open,
|
||||
title,
|
||||
onClose,
|
||||
children,
|
||||
maxWidth = "max-w-md",
|
||||
}: FormModalProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className={cn("p-0", maxWidth)}>
|
||||
<DialogHeader className="px-5 py-4 mb-0">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="px-5 pb-5">{children}</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
103
packages/web/src/components/shared/InlineEditor.tsx
Normal file
103
packages/web/src/components/shared/InlineEditor.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { Check, Pencil, X } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
interface InlineEditorProps {
|
||||
value: string;
|
||||
onSave: (value: string) => void;
|
||||
loading?: boolean;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function InlineEditor({
|
||||
value,
|
||||
onSave,
|
||||
loading = false,
|
||||
placeholder = "Click to edit",
|
||||
className = "",
|
||||
}: InlineEditorProps) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(value);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (editing) {
|
||||
setDraft(value);
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
}
|
||||
}, [editing, value]);
|
||||
|
||||
const commit = () => {
|
||||
if (draft.trim() && draft !== value) onSave(draft.trim());
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
setDraft(value);
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
if (!editing) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(true)}
|
||||
className={`group flex items-center gap-1.5 text-left transition-colors ${className}`}
|
||||
style={{ color: "var(--text-1)" }}
|
||||
>
|
||||
<span>{value || <span style={{ color: "var(--text-4)" }}>{placeholder}</span>}</span>
|
||||
<Pencil
|
||||
className="w-3 h-3 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0"
|
||||
style={{ color: "var(--text-4)" }}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") commit();
|
||||
if (e.key === "Escape") cancel();
|
||||
}}
|
||||
onBlur={commit}
|
||||
disabled={loading}
|
||||
className="text-sm px-2 py-0.5 rounded-md flex-1 min-w-0"
|
||||
style={{
|
||||
background: "var(--surface)",
|
||||
border: "1px solid var(--accent-border)",
|
||||
color: "var(--text-1)",
|
||||
outline: "none",
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
commit();
|
||||
}}
|
||||
className="p-1 rounded"
|
||||
style={{ color: "var(--accent-text)" }}
|
||||
>
|
||||
<Check className="w-3.5 h-3.5" strokeWidth={2.5} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
cancel();
|
||||
}}
|
||||
className="p-1 rounded"
|
||||
style={{ color: "var(--text-4)" }}
|
||||
>
|
||||
<X className="w-3.5 h-3.5" strokeWidth={2.5} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
packages/web/src/components/shared/JsonViewer.tsx
Normal file
38
packages/web/src/components/shared/JsonViewer.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
interface JsonViewerProps {
|
||||
data: unknown;
|
||||
maxHeight?: string;
|
||||
}
|
||||
|
||||
export function JsonViewer({ data, maxHeight = "200px" }: JsonViewerProps) {
|
||||
if (data === null || data === undefined) {
|
||||
return (
|
||||
<span className="text-xs italic" style={{ color: "var(--text-4)" }}>
|
||||
empty
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const isEmpty =
|
||||
typeof data === "object" && data !== null && Object.keys(data as object).length === 0;
|
||||
if (isEmpty) {
|
||||
return (
|
||||
<span className="text-xs italic" style={{ color: "var(--text-4)" }}>
|
||||
{}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<pre
|
||||
className="text-xs rounded-xl p-3 overflow-auto font-mono"
|
||||
style={{
|
||||
maxHeight,
|
||||
background: "var(--bg)",
|
||||
border: "1px solid var(--border)",
|
||||
color: "var(--text-2)",
|
||||
}}
|
||||
>
|
||||
{JSON.stringify(data, null, 2)}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
41
packages/web/src/components/shared/LoadingSpinner.tsx
Normal file
41
packages/web/src/components/shared/LoadingSpinner.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { COLOR } from "@/lib/constants";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
size?: "sm" | "md" | "lg";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const sizes = { sm: 16, md: 24, lg: 40 };
|
||||
|
||||
export function LoadingSpinner({ size = "md", className = "" }: LoadingSpinnerProps) {
|
||||
const s = sizes[size];
|
||||
return (
|
||||
<motion.div
|
||||
className={className}
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 1, repeat: Number.POSITIVE_INFINITY, ease: "linear" }}
|
||||
style={{
|
||||
width: s,
|
||||
height: s,
|
||||
borderRadius: "50%",
|
||||
border: `2px solid ${COLOR.accentSpinnerTrack}`,
|
||||
borderTopColor: COLOR.accent,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function PageLoader() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-48 gap-3">
|
||||
<LoadingSpinner size="lg" />
|
||||
<motion.div
|
||||
className="h-px w-24"
|
||||
style={{ background: `linear-gradient(90deg, transparent, ${COLOR.accent}, transparent)` }}
|
||||
animate={{ opacity: [0.4, 1, 0.4] }}
|
||||
transition={{ duration: 1.5, repeat: Number.POSITIVE_INFINITY, ease: "easeInOut" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
549
packages/web/src/components/shared/MarkdownRenderer.tsx
Normal file
549
packages/web/src/components/shared/MarkdownRenderer.tsx
Normal file
@@ -0,0 +1,549 @@
|
||||
import { TimestampChip } from "@/components/shared/TimestampChip";
|
||||
import { COLOR } from "@/lib/constants";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { DateTime } from "luxon";
|
||||
import ReactMarkdown, { type Components } from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type Confidence = "high" | "medium" | "low";
|
||||
|
||||
interface PatternBlock {
|
||||
confidence: Confidence;
|
||||
description: string;
|
||||
type: string;
|
||||
sources: string[];
|
||||
}
|
||||
|
||||
interface ContradictionBlock {
|
||||
description: string;
|
||||
conflictingStatements: string[];
|
||||
}
|
||||
|
||||
interface ContentSection {
|
||||
heading: string | null;
|
||||
rawBody: string;
|
||||
}
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
const TIMESTAMP_LINE_RE = /^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\]\s*(.*)/s;
|
||||
|
||||
const CONFIDENCE_STYLE: Record<Confidence, { bg: string; text: string; border: string }> = {
|
||||
high: { bg: COLOR.destructiveDim, text: COLOR.destructive, border: COLOR.destructiveBorder },
|
||||
medium: { bg: COLOR.warningDim, text: COLOR.warning, border: COLOR.warningBorder },
|
||||
low: { bg: COLOR.successDim, text: COLOR.success, border: COLOR.successBorder },
|
||||
};
|
||||
|
||||
const CONFIDENCE_ORDER: Record<Confidence, number> = { high: 0, medium: 1, low: 2 };
|
||||
|
||||
// 10+ alphanumeric/_/- chars in brackets that are NOT a timestamp
|
||||
const CITATION_RE = /\[([A-Za-z0-9_-]{10,})\]/g;
|
||||
|
||||
// ─── Preprocessor ─────────────────────────────────────────────────────────────
|
||||
|
||||
function preprocessContent(content: string): string {
|
||||
return content
|
||||
.replace(/^ {3}/gm, "")
|
||||
.replace(/^(- .+)\n(\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\])/gm, "$1\n\n$2");
|
||||
}
|
||||
|
||||
// ─── Section splitter ─────────────────────────────────────────────────────────
|
||||
|
||||
function splitIntoSections(content: string): ContentSection[] {
|
||||
const result: ContentSection[] = [];
|
||||
const parts = content.split(/^(## .+)$/m);
|
||||
|
||||
if (parts[0].trim()) {
|
||||
result.push({ heading: null, rawBody: parts[0] });
|
||||
}
|
||||
|
||||
for (let i = 1; i < parts.length; i += 2) {
|
||||
result.push({
|
||||
heading: parts[i].replace(/^## /, "").trim(),
|
||||
rawBody: parts[i + 1] ?? "",
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── Block parsers ────────────────────────────────────────────────────────────
|
||||
|
||||
function parsePatternBlocks(sectionBody: string): PatternBlock[] {
|
||||
const blocks = sectionBody.split(/\n\n+/);
|
||||
const result: PatternBlock[] = [];
|
||||
|
||||
for (const block of blocks) {
|
||||
const lines = block.split("\n");
|
||||
const firstLine = (lines[0] ?? "").trim();
|
||||
const patternMatch = /\*\*Pattern\*\* \[(high|medium|low)\]: (.+)/i.exec(firstLine);
|
||||
if (!patternMatch) continue;
|
||||
|
||||
const confidence = patternMatch[1].toLowerCase() as Confidence;
|
||||
const description = patternMatch[2].trim();
|
||||
let type = "";
|
||||
const sources: string[] = [];
|
||||
let inSources = false;
|
||||
|
||||
for (const line of lines.slice(1)) {
|
||||
const t = line.trim();
|
||||
if (!t) continue;
|
||||
const typeMatch = /\*\*Type\*\*: (.+)/.exec(t);
|
||||
if (typeMatch) {
|
||||
type = typeMatch[1].trim();
|
||||
continue;
|
||||
}
|
||||
if (/\*\*Sources\*\*:/.test(t)) {
|
||||
inSources = true;
|
||||
continue;
|
||||
}
|
||||
if (inSources && t.startsWith("- ")) {
|
||||
sources.push(t.slice(2).trim());
|
||||
}
|
||||
}
|
||||
|
||||
result.push({ confidence, description, type, sources });
|
||||
}
|
||||
|
||||
return result.sort((a, b) => CONFIDENCE_ORDER[a.confidence] - CONFIDENCE_ORDER[b.confidence]);
|
||||
}
|
||||
|
||||
function parseContradictionBlocks(sectionBody: string): ContradictionBlock[] {
|
||||
const blocks = sectionBody.split(/\n\n+/);
|
||||
const result: ContradictionBlock[] = [];
|
||||
|
||||
for (const block of blocks) {
|
||||
const lines = block.split("\n");
|
||||
const firstLine = (lines[0] ?? "").trim();
|
||||
const descMatch = /\*\*CONTRADICTION\*\*: (.+)/i.exec(firstLine);
|
||||
if (!descMatch) continue;
|
||||
|
||||
const description = descMatch[1].trim();
|
||||
const conflictingStatements: string[] = [];
|
||||
let inStatements = false;
|
||||
|
||||
for (const line of lines.slice(1)) {
|
||||
const t = line.trim();
|
||||
if (!t) continue;
|
||||
if (/\*\*Conflicting statements?\*\*:/.test(t)) {
|
||||
inStatements = true;
|
||||
continue;
|
||||
}
|
||||
if (inStatements && t.startsWith("- ")) {
|
||||
conflictingStatements.push(t.slice(2).trim());
|
||||
}
|
||||
}
|
||||
|
||||
result.push({ description, conflictingStatements });
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── Inline citation renderer ─────────────────────────────────────────────────
|
||||
|
||||
function renderWithCitations(text: string, workspaceId?: string): React.ReactNode[] {
|
||||
const parts: React.ReactNode[] = [];
|
||||
let lastIndex = 0;
|
||||
CITATION_RE.lastIndex = 0;
|
||||
let match = CITATION_RE.exec(text);
|
||||
|
||||
while (match !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(text.slice(lastIndex, match.index));
|
||||
}
|
||||
const id = match[1];
|
||||
const label = `${id.slice(0, 8)}…`;
|
||||
const chipStyle = {
|
||||
background: COLOR.accentDim,
|
||||
color: COLOR.accentText,
|
||||
border: `1px solid ${COLOR.accentBorder}`,
|
||||
};
|
||||
|
||||
if (workspaceId) {
|
||||
parts.push(
|
||||
<Link
|
||||
key={`${id}-${match.index}`}
|
||||
to="/workspaces/$workspaceId/sessions/$sessionId"
|
||||
params={{ workspaceId, sessionId: id } as never}
|
||||
className="font-mono text-xs px-1.5 py-0.5 rounded hover:opacity-80 transition-opacity"
|
||||
style={chipStyle}
|
||||
>
|
||||
{label}
|
||||
</Link>,
|
||||
);
|
||||
} else {
|
||||
parts.push(
|
||||
<span
|
||||
key={`${id}-${match.index}`}
|
||||
className="font-mono text-xs px-1.5 py-0.5 rounded"
|
||||
style={chipStyle}
|
||||
>
|
||||
{label}
|
||||
</span>,
|
||||
);
|
||||
}
|
||||
|
||||
lastIndex = match.index + match[0].length;
|
||||
match = CITATION_RE.exec(text);
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(text.slice(lastIndex));
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
// ─── Section renderers ────────────────────────────────────────────────────────
|
||||
|
||||
function PatternCard({ block }: { block: PatternBlock }) {
|
||||
const cs = CONFIDENCE_STYLE[block.confidence];
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg p-4 mb-3"
|
||||
style={{ background: COLOR.cardBaseBg, border: `1px solid ${COLOR.cardBaseBorder}` }}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<span
|
||||
className="text-xs font-mono px-2 py-0.5 rounded-full uppercase font-semibold tracking-wide"
|
||||
style={{ background: cs.bg, color: cs.text, border: `1px solid ${cs.border}` }}
|
||||
>
|
||||
{block.confidence}
|
||||
</span>
|
||||
{block.type && (
|
||||
<span
|
||||
className="text-xs font-mono px-2 py-0.5 rounded"
|
||||
style={{
|
||||
background: COLOR.accentSubtle,
|
||||
color: COLOR.accentText,
|
||||
border: `1px solid ${COLOR.accentBorder}`,
|
||||
}}
|
||||
>
|
||||
{block.type}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed mb-0" style={{ color: "var(--text-2)" }}>
|
||||
{block.description}
|
||||
</p>
|
||||
{block.sources.length > 0 && (
|
||||
<div
|
||||
className="mt-3 pt-3 space-y-1"
|
||||
style={{ borderTop: `1px solid ${COLOR.cardBaseBorder}` }}
|
||||
>
|
||||
<p className="text-xs font-medium mb-1.5" style={{ color: "var(--text-3)" }}>
|
||||
Sources
|
||||
</p>
|
||||
{block.sources.map((s) => {
|
||||
const isOverflow = /^\.\.\. and \d+ more$/.test(s);
|
||||
return (
|
||||
<div key={s} className="flex items-start gap-1.5">
|
||||
{!isOverflow && (
|
||||
<span className="mt-1 shrink-0 text-xs" style={{ color: COLOR.accent }}>
|
||||
•
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={isOverflow ? "text-xs italic pl-3" : "text-xs leading-relaxed"}
|
||||
style={{
|
||||
color: isOverflow ? "var(--text-4)" : "var(--text-3)",
|
||||
}}
|
||||
>
|
||||
{s}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ContradictionCard({
|
||||
block,
|
||||
workspaceId,
|
||||
}: {
|
||||
block: ContradictionBlock;
|
||||
workspaceId?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg p-4 mb-3"
|
||||
style={{
|
||||
background: COLOR.destructiveDim,
|
||||
border: `1px solid ${COLOR.destructiveBorder}`,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span
|
||||
className="text-xs font-mono px-2 py-0.5 rounded-full uppercase font-semibold tracking-wide"
|
||||
style={{
|
||||
background: "rgba(239,68,68,0.12)",
|
||||
color: COLOR.destructive,
|
||||
border: `1px solid ${COLOR.destructiveBorder}`,
|
||||
}}
|
||||
>
|
||||
Contradiction
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed" style={{ color: "var(--text-2)" }}>
|
||||
{renderWithCitations(block.description, workspaceId)}
|
||||
</p>
|
||||
{block.conflictingStatements.length > 0 && (
|
||||
<div
|
||||
className="mt-3 pt-3 space-y-2"
|
||||
style={{ borderTop: `1px solid ${COLOR.destructiveBorder}` }}
|
||||
>
|
||||
<p className="text-xs font-medium mb-1.5" style={{ color: "var(--text-3)" }}>
|
||||
Conflicting statements
|
||||
</p>
|
||||
{block.conflictingStatements.map((s, i) => (
|
||||
<div
|
||||
key={s}
|
||||
className="flex items-start gap-2 rounded px-3 py-2"
|
||||
style={{
|
||||
background: i === 0 ? "rgba(239,68,68,0.06)" : "rgba(248,113,113,0.04)",
|
||||
border: `1px solid ${COLOR.destructiveBorder}`,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="text-xs font-mono shrink-0 mt-0.5"
|
||||
style={{ color: COLOR.destructiveMuted }}
|
||||
>
|
||||
{i === 0 ? "A" : "B"}
|
||||
</span>
|
||||
<span className="text-sm leading-relaxed" style={{ color: "var(--text-2)" }}>
|
||||
{s}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Standard markdown pipeline ───────────────────────────────────────────────
|
||||
|
||||
function flattenChildren(children: React.ReactNode): string {
|
||||
if (typeof children === "string") return children;
|
||||
if (Array.isArray(children)) return children.map(flattenChildren).join("");
|
||||
if (children && typeof children === "object" && "props" in (children as object)) {
|
||||
return flattenChildren((children as { props: { children?: React.ReactNode } }).props.children);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function Paragraph({ children }: { children?: React.ReactNode }) {
|
||||
const text = flattenChildren(children);
|
||||
const lines = text.split("\n").filter(Boolean);
|
||||
|
||||
// All lines are timestamps → sorted chip list
|
||||
if (lines.length > 0 && lines.every((l) => TIMESTAMP_LINE_RE.test(l))) {
|
||||
const sorted = [...lines].sort((a, b) => {
|
||||
const ta = DateTime.fromFormat(TIMESTAMP_LINE_RE.exec(a)?.[1] ?? "", "yyyy-MM-dd HH:mm:ss", {
|
||||
zone: "utc",
|
||||
});
|
||||
const tb = DateTime.fromFormat(TIMESTAMP_LINE_RE.exec(b)?.[1] ?? "", "yyyy-MM-dd HH:mm:ss", {
|
||||
zone: "utc",
|
||||
});
|
||||
return tb.toMillis() - ta.toMillis();
|
||||
});
|
||||
return (
|
||||
<div className="space-y-0.5 my-2">
|
||||
{sorted.map((line) => {
|
||||
const m = TIMESTAMP_LINE_RE.exec(line);
|
||||
return (
|
||||
<div
|
||||
key={line}
|
||||
className="flex items-start gap-3 py-1 px-1 rounded-sm"
|
||||
style={{ borderBottom: "1px solid var(--border)" }}
|
||||
>
|
||||
<TimestampChip value={m?.[1] ?? ""} className="mt-0.5" />
|
||||
<span
|
||||
className="text-sm leading-relaxed flex-1 min-w-0"
|
||||
style={{ color: "var(--text-2)" }}
|
||||
>
|
||||
{m?.[2]}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// First line is timestamp + trailing label(s) → deductive entry header
|
||||
const firstMatch = lines.length > 1 ? TIMESTAMP_LINE_RE.exec(lines[0]) : null;
|
||||
if (firstMatch) {
|
||||
return (
|
||||
<div className="mt-3 mb-1 pb-1" style={{ borderBottom: "1px solid var(--border)" }}>
|
||||
<div className="flex items-start gap-3">
|
||||
<TimestampChip value={firstMatch[1]} className="mt-0.5 shrink-0" />
|
||||
<span className="text-sm leading-relaxed" style={{ color: "var(--text-2)" }}>
|
||||
{firstMatch[2]}
|
||||
</span>
|
||||
</div>
|
||||
{lines.slice(1).map((l) => (
|
||||
<p key={l} className="text-xs mt-1 font-medium" style={{ color: "var(--text-3)" }}>
|
||||
{l}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<p className="text-sm leading-relaxed mb-3" style={{ color: "var(--text-2)" }}>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const SECTION_H2_CLASS = "text-sm font-semibold mt-4 mb-3 pb-1 uppercase tracking-wider";
|
||||
const SECTION_H2_STYLE = { color: "var(--accent-text)", borderBottom: "1px solid var(--border)" };
|
||||
|
||||
const COMPONENTS: Components = {
|
||||
h1: ({ children }) => (
|
||||
<h1
|
||||
className="text-base font-semibold mt-4 mb-2 pb-1"
|
||||
style={{ color: "var(--text-1)", borderBottom: "1px solid var(--border)" }}
|
||||
>
|
||||
{children}
|
||||
</h1>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<h2 className={SECTION_H2_CLASS} style={SECTION_H2_STYLE}>
|
||||
{children}
|
||||
</h2>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<h3 className="text-sm font-medium mt-3 mb-1.5" style={{ color: "var(--text-1)" }}>
|
||||
{children}
|
||||
</h3>
|
||||
),
|
||||
p: Paragraph,
|
||||
ul: ({ children }) => (
|
||||
<ul className="text-sm space-y-1 mb-3 pl-4 list-disc" style={{ color: "var(--text-2)" }}>
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol className="text-sm space-y-1 mb-3 pl-4 list-decimal" style={{ color: "var(--text-2)" }}>
|
||||
{children}
|
||||
</ol>
|
||||
),
|
||||
li: ({ children }) => <li className="leading-relaxed">{children}</li>,
|
||||
code: ({ children, className }) => {
|
||||
const isBlock = className?.includes("language-");
|
||||
if (isBlock) {
|
||||
return (
|
||||
<pre
|
||||
className="text-xs font-mono rounded-lg p-3 overflow-x-auto my-3"
|
||||
style={{
|
||||
background: "var(--bg-3)",
|
||||
border: "1px solid var(--border)",
|
||||
color: "var(--text-2)",
|
||||
}}
|
||||
>
|
||||
<code>{children}</code>
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<code
|
||||
className="text-xs font-mono px-1.5 py-0.5 rounded"
|
||||
style={{
|
||||
background: "var(--bg-3)",
|
||||
color: "var(--accent-text)",
|
||||
border: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote
|
||||
className="text-sm pl-3 my-3 italic"
|
||||
style={{ borderLeft: "3px solid var(--accent-border)", color: "var(--text-3)" }}
|
||||
>
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
hr: () => (
|
||||
<hr style={{ border: "none", borderTop: "1px solid var(--border)" }} className="my-4" />
|
||||
),
|
||||
strong: ({ children }) => (
|
||||
<strong className="font-semibold" style={{ color: "var(--text-1)" }}>
|
||||
{children}
|
||||
</strong>
|
||||
),
|
||||
};
|
||||
|
||||
// ─── Export ───────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Props {
|
||||
content: string;
|
||||
workspaceId?: string;
|
||||
}
|
||||
|
||||
export function MarkdownRenderer({ content, workspaceId }: Props) {
|
||||
const sections = splitIntoSections(content);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{sections.map((section) => {
|
||||
const sectionKey = `${section.heading ?? ""}-${section.rawBody.slice(0, 30)}`;
|
||||
if (section.heading === "Inductive Observations") {
|
||||
const blocks = parsePatternBlocks(section.rawBody);
|
||||
return (
|
||||
<div key={sectionKey}>
|
||||
<h2 className={SECTION_H2_CLASS} style={SECTION_H2_STYLE}>
|
||||
Inductive Observations
|
||||
</h2>
|
||||
{blocks.map((b) => (
|
||||
<PatternCard
|
||||
key={`${b.confidence}-${b.type}-${b.description.slice(0, 20)}`}
|
||||
block={b}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (section.heading === "Contradictions") {
|
||||
const blocks = parseContradictionBlocks(section.rawBody);
|
||||
return (
|
||||
<div key={sectionKey}>
|
||||
<h2 className={SECTION_H2_CLASS} style={SECTION_H2_STYLE}>
|
||||
Contradictions
|
||||
</h2>
|
||||
{blocks.map((b) => (
|
||||
<ContradictionCard
|
||||
key={b.description.slice(0, 40)}
|
||||
block={b}
|
||||
workspaceId={workspaceId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const sectionContent = section.heading
|
||||
? `## ${section.heading}\n${section.rawBody}`
|
||||
: section.rawBody;
|
||||
|
||||
return (
|
||||
<ReactMarkdown key={sectionKey} remarkPlugins={[remarkGfm]} components={COMPONENTS}>
|
||||
{preprocessContent(sectionContent)}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
36
packages/web/src/components/shared/Pagination.tsx
Normal file
36
packages/web/src/components/shared/Pagination.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { MonoCaption } from "@/components/ui/typography";
|
||||
|
||||
interface PaginationProps {
|
||||
page: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
export function Pagination({ page, totalPages, onPageChange }: PaginationProps) {
|
||||
if (totalPages <= 1) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 mt-6">
|
||||
<Button
|
||||
variant="surface"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
disabled={page <= 1}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<MonoCaption className="px-2">
|
||||
{page} / {totalPages}
|
||||
</MonoCaption>
|
||||
<Button
|
||||
variant="surface"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
disabled={page >= totalPages}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
240
packages/web/src/components/shared/PeerCardViewer.tsx
Normal file
240
packages/web/src/components/shared/PeerCardViewer.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { COLOR } from "@/lib/constants";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
interface Props {
|
||||
lines: string[];
|
||||
}
|
||||
|
||||
// ALL_CAPS_WORD: — no lowercase letters in key
|
||||
const CAPS_RE = /^([A-Z][A-Z0-9_]+):\s*([\s\S]*)/;
|
||||
// Title Case Word: — starts capital, must contain at least one lowercase
|
||||
const TITLE_RE = /^([A-Z][a-zA-Z0-9][a-zA-Z0-9 ]*):\s*([\s\S]*)/;
|
||||
|
||||
type ParsedLine =
|
||||
| { kind: "fact"; text: string }
|
||||
| { kind: "caps"; key: string; value: string }
|
||||
| { kind: "title"; key: string; value: string };
|
||||
|
||||
const PALETTE: Array<{ bg: string; text: string; border: string; dot: string }> = [
|
||||
{ bg: "rgba(52,211,153,0.08)", text: "#34d399", border: "rgba(52,211,153,0.25)", dot: "#34d399" },
|
||||
{ bg: "rgba(245,158,11,0.08)", text: "#f59e0b", border: "rgba(245,158,11,0.25)", dot: "#f59e0b" },
|
||||
{ bg: "rgba(14,165,233,0.08)", text: "#38bdf8", border: "rgba(14,165,233,0.25)", dot: "#38bdf8" },
|
||||
{ bg: "rgba(236,72,153,0.08)", text: "#f472b6", border: "rgba(236,72,153,0.25)", dot: "#f472b6" },
|
||||
{ bg: "rgba(168,85,247,0.08)", text: "#c084fc", border: "rgba(168,85,247,0.25)", dot: "#c084fc" },
|
||||
{ bg: "rgba(239,68,68,0.08)", text: "#f87171", border: "rgba(239,68,68,0.25)", dot: "#f87171" },
|
||||
{ bg: "rgba(34,197,94,0.08)", text: "#4ade80", border: "rgba(34,197,94,0.25)", dot: "#4ade80" },
|
||||
{ bg: "rgba(251,146,60,0.08)", text: "#fb923c", border: "rgba(251,146,60,0.25)", dot: "#fb923c" },
|
||||
];
|
||||
|
||||
function hashPalette(word: string): number {
|
||||
let h = 5381;
|
||||
for (let i = 0; i < word.length; i++) h = ((h * 33) ^ word.charCodeAt(i)) >>> 0;
|
||||
return h % PALETTE.length;
|
||||
}
|
||||
|
||||
function toLabel(key: string): string {
|
||||
const s = key.toLowerCase().replace(/_/g, " ");
|
||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||
}
|
||||
|
||||
function parseLine(line: string): ParsedLine {
|
||||
const caps = CAPS_RE.exec(line);
|
||||
if (caps) return { kind: "caps", key: caps[1], value: caps[2].trim() };
|
||||
const title = TITLE_RE.exec(line);
|
||||
if (title && /[a-z]/.test(title[1])) {
|
||||
return { kind: "title", key: title[1], value: title[2].trim() };
|
||||
}
|
||||
return { kind: "fact", text: line };
|
||||
}
|
||||
|
||||
interface CapsGroup {
|
||||
key: string;
|
||||
items: string[];
|
||||
}
|
||||
|
||||
interface Parsed {
|
||||
titlePairs: Array<{ key: string; value: string }>;
|
||||
facts: string[];
|
||||
capsGroups: CapsGroup[];
|
||||
}
|
||||
|
||||
function parse(lines: string[]): Parsed {
|
||||
const titlePairs: Array<{ key: string; value: string }> = [];
|
||||
const facts: string[] = [];
|
||||
const capsMap = new Map<string, string[]>();
|
||||
const capsOrder: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const p = parseLine(line);
|
||||
if (p.kind === "title") {
|
||||
titlePairs.push({ key: p.key, value: p.value });
|
||||
} else if (p.kind === "caps") {
|
||||
if (!capsMap.has(p.key)) {
|
||||
capsMap.set(p.key, []);
|
||||
capsOrder.push(p.key);
|
||||
}
|
||||
capsMap.get(p.key)?.push(p.value);
|
||||
} else {
|
||||
facts.push(p.text);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
titlePairs,
|
||||
facts,
|
||||
capsGroups: capsOrder.map((k) => ({ key: k, items: capsMap.get(k) ?? [] })),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Metadata table (Title Case: pairs) ──────────────────────────────────────
|
||||
|
||||
function MetadataCard({ pairs }: { pairs: Array<{ key: string; value: string }> }) {
|
||||
if (pairs.length === 0) return null;
|
||||
return (
|
||||
<div className="rounded-lg overflow-hidden" style={{ border: "1px solid var(--border-2)" }}>
|
||||
<dl className="divide-y" style={{ "--tw-divide-opacity": 1 } as React.CSSProperties}>
|
||||
{pairs.map(({ key, value }, i) => (
|
||||
<div
|
||||
key={key}
|
||||
className="grid grid-cols-[9rem_1fr] gap-3 px-4 py-2.5 text-sm"
|
||||
style={{ background: i % 2 === 0 ? "var(--surface)" : "var(--bg-3)" }}
|
||||
>
|
||||
<dt className="font-medium break-words" style={{ color: "var(--text-3)" }}>
|
||||
{key}
|
||||
</dt>
|
||||
<dd className="min-w-0 break-words" style={{ color: "var(--text-1)" }}>
|
||||
{value || <span style={{ color: "var(--text-4)" }}>—</span>}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Collapsible section (ALL_CAPS: groups + Facts) ───────────────────────────
|
||||
|
||||
interface SectionStyle {
|
||||
bg: string;
|
||||
text: string;
|
||||
border: string;
|
||||
}
|
||||
|
||||
const FACTS_STYLE: SectionStyle = {
|
||||
bg: COLOR.accentDim,
|
||||
text: "#a5b4fc",
|
||||
border: COLOR.accentBorder,
|
||||
};
|
||||
|
||||
function CollapsibleSection({
|
||||
label,
|
||||
count,
|
||||
style,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
count: number;
|
||||
style: SectionStyle;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState(true);
|
||||
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
<div className="rounded-lg overflow-hidden" style={{ border: `1px solid ${style.border}` }}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"w-full flex items-center justify-between px-4 py-2.5 text-sm font-medium",
|
||||
"transition-opacity hover:opacity-80",
|
||||
)}
|
||||
style={{ background: style.bg, color: style.text }}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{label}
|
||||
<span
|
||||
className="text-xs font-mono px-1.5 py-0.5 rounded"
|
||||
style={{
|
||||
background: "rgba(0,0,0,0.2)",
|
||||
color: style.text,
|
||||
opacity: 0.75,
|
||||
}}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronDown
|
||||
className="w-4 h-4 transition-transform duration-200"
|
||||
style={{ transform: open ? "rotate(0deg)" : "rotate(-90deg)" }}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>{children}</CollapsibleContent>
|
||||
</div>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemList({ items }: { items: string[] }) {
|
||||
return (
|
||||
<ul>
|
||||
{items.map((item) => (
|
||||
<li
|
||||
key={item}
|
||||
className="px-4 py-2.5 text-sm leading-relaxed break-words"
|
||||
style={{
|
||||
color: "var(--text-2)",
|
||||
borderTop: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Export ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export function PeerCardViewer({ lines }: Props) {
|
||||
if (!lines || lines.length === 0) {
|
||||
return (
|
||||
<p className="text-sm" style={{ color: "var(--text-4)" }}>
|
||||
No card set.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const { titlePairs, facts, capsGroups } = parse(lines);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<MetadataCard pairs={titlePairs} />
|
||||
|
||||
{facts.length > 0 && (
|
||||
<CollapsibleSection label="Facts" count={facts.length} style={FACTS_STYLE}>
|
||||
<ItemList items={facts} />
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{capsGroups.map((g) => {
|
||||
const p = PALETTE[hashPalette(g.key)];
|
||||
return (
|
||||
<CollapsibleSection
|
||||
key={g.key}
|
||||
label={toLabel(g.key)}
|
||||
count={g.items.length}
|
||||
style={{ bg: p.bg, text: p.text, border: p.border }}
|
||||
>
|
||||
<ItemList items={g.items} />
|
||||
</CollapsibleSection>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
62
packages/web/src/components/shared/SortControl.tsx
Normal file
62
packages/web/src/components/shared/SortControl.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { COLOR } from "@/lib/constants";
|
||||
import { ArrowDown, ArrowUp } from "lucide-react";
|
||||
|
||||
export type SortDir = "asc" | "desc";
|
||||
|
||||
export interface SortOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface SortControlProps {
|
||||
options: SortOption[];
|
||||
field: string;
|
||||
dir: SortDir;
|
||||
onChange: (field: string, dir: SortDir) => void;
|
||||
}
|
||||
|
||||
export function SortControl({ options, field, dir, onChange }: SortControlProps) {
|
||||
function handleClick(value: string) {
|
||||
if (value === field) {
|
||||
// Toggle direction on active option
|
||||
onChange(value, dir === "desc" ? "asc" : "desc");
|
||||
} else {
|
||||
// New field always starts desc (most-recent-first convention)
|
||||
onChange(value, "desc");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs" style={{ color: "var(--text-4)" }}>
|
||||
Sort:
|
||||
</span>
|
||||
<div className="flex items-center gap-0.5">
|
||||
{options.map((opt) => {
|
||||
const active = opt.value === field;
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => handleClick(opt.value)}
|
||||
className="inline-flex items-center gap-0.5 px-2 py-0.5 rounded text-xs font-medium transition-all"
|
||||
style={{
|
||||
background: active ? COLOR.accentSubtle : "transparent",
|
||||
color: active ? "var(--accent-text)" : "var(--text-3)",
|
||||
border: `1px solid ${active ? COLOR.accentBorder : "transparent"}`,
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
{active &&
|
||||
(dir === "desc" ? (
|
||||
<ArrowDown className="w-2.5 h-2.5" strokeWidth={2.5} />
|
||||
) : (
|
||||
<ArrowUp className="w-2.5 h-2.5" strokeWidth={2.5} />
|
||||
))}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
65
packages/web/src/components/shared/TimestampChip.tsx
Normal file
65
packages/web/src/components/shared/TimestampChip.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { COLOR } from "@/lib/constants";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { DateTime } from "luxon";
|
||||
|
||||
interface Props {
|
||||
/** ISO-like string: "2026-04-24 18:18:48" or any Luxon-parseable string */
|
||||
value: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function parseTimestamp(value: string): DateTime {
|
||||
// Honcho stores timestamps as UTC without timezone suffix — parse as UTC, display in local
|
||||
const dt = DateTime.fromFormat(value, "yyyy-MM-dd HH:mm:ss", { zone: "utc" });
|
||||
if (dt.isValid) return dt.toLocal();
|
||||
// Fall back to ISO (may include timezone info)
|
||||
return DateTime.fromISO(value);
|
||||
}
|
||||
|
||||
function formatDisplay(dt: DateTime): { label: string; isRelative: boolean } {
|
||||
const now = DateTime.now();
|
||||
const diffMs = Math.abs(now.diff(dt, "milliseconds").milliseconds);
|
||||
|
||||
// Today → relative time ("2 hours ago", "just now")
|
||||
if (dt.hasSame(now, "day")) return { label: dt.toRelative() ?? "just now", isRelative: true };
|
||||
// Within the past year → month + day + time
|
||||
if (diffMs < 365 * 24 * 3600 * 1000)
|
||||
return { label: dt.toFormat("MMM d HH:mm"), isRelative: false };
|
||||
// Older → full date
|
||||
return { label: dt.toFormat("yyyy-MM-dd HH:mm"), isRelative: false };
|
||||
}
|
||||
|
||||
export function TimestampChip({ value, className }: Props) {
|
||||
const dt = parseTimestamp(value);
|
||||
if (!dt.isValid) {
|
||||
return (
|
||||
<span className={cn("font-mono text-xs", className)} style={{ color: "var(--text-4)" }}>
|
||||
{value}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const { label: display, isRelative } = formatDisplay(dt);
|
||||
const full = dt.toFormat("yyyy-MM-dd HH:mm:ss ZZZZ");
|
||||
const relative = dt.toRelative() ?? "";
|
||||
|
||||
return (
|
||||
<time
|
||||
dateTime={dt.toISO() ?? value}
|
||||
title={isRelative ? full : `${full} · ${relative}`}
|
||||
className={cn(
|
||||
"inline-flex items-center shrink-0 text-xs px-1.5 py-0.5 rounded",
|
||||
"select-none cursor-default",
|
||||
isRelative ? "font-sans" : "font-mono",
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
background: COLOR.accentSubtle,
|
||||
color: "var(--accent-text)",
|
||||
border: `1px solid ${COLOR.accentBorder}`,
|
||||
}}
|
||||
>
|
||||
{display}
|
||||
</time>
|
||||
);
|
||||
}
|
||||
29
packages/web/src/components/ui/badge.tsx
Normal file
29
packages/web/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { type VariantProps, cva } from "class-variance-authority";
|
||||
import type { HTMLAttributes } from "react";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-xs font-medium transition-colors",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-transparent bg-primary/15 text-primary",
|
||||
secondary: "border-transparent bg-secondary text-muted-foreground",
|
||||
outline: "border-border text-muted-foreground",
|
||||
destructive: "border-transparent bg-red-500/15 text-red-400",
|
||||
success: "border-transparent bg-emerald-500/15 text-emerald-400",
|
||||
warning: "border-transparent bg-amber-500/15 text-amber-400",
|
||||
blue: "border-transparent bg-sky-500/15 text-sky-400",
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: "default" },
|
||||
},
|
||||
);
|
||||
|
||||
interface BadgeProps extends HTMLAttributes<HTMLSpanElement>, VariantProps<typeof badgeVariants> {}
|
||||
|
||||
export function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <span className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||
}
|
||||
|
||||
export { badgeVariants };
|
||||
66
packages/web/src/components/ui/button.tsx
Normal file
66
packages/web/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { type VariantProps, cva } from "class-variance-authority";
|
||||
import { forwardRef } from "react";
|
||||
|
||||
const buttonVariants = cva(
|
||||
[
|
||||
"inline-flex items-center justify-center gap-1.5 rounded-lg font-medium transition-all",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-1)]",
|
||||
"disabled:opacity-50 disabled:pointer-events-none",
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
primary: ["text-white", "[background:var(--accent)]", "focus-visible:ring-[var(--accent)]"],
|
||||
accent: [
|
||||
"[background:var(--accent-dim)] [color:var(--accent-text)]",
|
||||
"[border:1px_solid_var(--accent-border)]",
|
||||
"focus-visible:ring-[var(--accent)]",
|
||||
],
|
||||
surface: [
|
||||
"[background:var(--surface)] [color:var(--text-2)]",
|
||||
"[border:1px_solid_var(--border)]",
|
||||
"focus-visible:ring-[var(--border)]",
|
||||
],
|
||||
ghost: [
|
||||
"[color:var(--text-3)]",
|
||||
"hover:[background:var(--surface)]",
|
||||
"focus-visible:ring-[var(--border)]",
|
||||
],
|
||||
destructive: [
|
||||
"bg-[rgba(239,68,68,0.08)] text-[#f87171]",
|
||||
"border border-[rgba(239,68,68,0.2)]",
|
||||
"focus-visible:ring-[#f87171]",
|
||||
],
|
||||
},
|
||||
size: {
|
||||
default: "px-4 py-2 text-sm",
|
||||
sm: "px-3 py-1.5 text-xs",
|
||||
icon: "p-1.5 text-sm",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "accent",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp ref={ref} className={cn(buttonVariants({ variant, size }), className)} {...props} />
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { buttonVariants };
|
||||
25
packages/web/src/components/ui/card.tsx
Normal file
25
packages/web/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { HTMLAttributes } from "react";
|
||||
|
||||
export function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("rounded-xl border border-border bg-card text-card-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("flex flex-col gap-1 px-5 py-4", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardTitle({ className, ...props }: HTMLAttributes<HTMLHeadingElement>) {
|
||||
return (
|
||||
<h3 className={cn("text-sm font-semibold leading-none tracking-tight", className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export function CardContent({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("px-5 pb-5", className)} {...props} />;
|
||||
}
|
||||
9
packages/web/src/components/ui/collapsible.tsx
Normal file
9
packages/web/src/components/ui/collapsible.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
|
||||
|
||||
const Collapsible = CollapsiblePrimitive.Root;
|
||||
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
|
||||
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
||||
105
packages/web/src/components/ui/dialog.tsx
Normal file
105
packages/web/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { X } from "lucide-react";
|
||||
import { forwardRef } from "react";
|
||||
|
||||
export const Dialog = DialogPrimitive.Root;
|
||||
export const DialogTrigger = DialogPrimitive.Trigger;
|
||||
export const DialogPortal = DialogPrimitive.Portal;
|
||||
export const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
export const DialogOverlay = forwardRef<
|
||||
React.ComponentRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 backdrop-blur-sm",
|
||||
"bg-black/60",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
export const DialogContent = forwardRef<
|
||||
React.ComponentRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-1/2 top-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2",
|
||||
"rounded-2xl p-6 shadow-2xl",
|
||||
"[background:var(--bg-2)] [border:1px_solid_var(--border-2)]",
|
||||
"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-[state=closed]:slide-out-to-top-[2%] data-[state=open]:slide-in-from-top-[2%]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close
|
||||
className={cn(
|
||||
"absolute right-4 top-4 rounded-lg p-1 transition-colors",
|
||||
"[color:var(--text-4)] hover:[color:var(--text-2)]",
|
||||
"focus:outline-none focus:ring-2 focus:ring-[var(--accent)] focus:ring-offset-2 focus:ring-offset-[var(--bg-2)]",
|
||||
)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
export function DialogHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-1.5 pb-4 mb-4 [border-bottom:1px_solid_var(--border)]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function DialogFooter({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("flex justify-end gap-2 pt-4 mt-4", className)} {...props} />;
|
||||
}
|
||||
|
||||
export const DialogTitle = forwardRef<
|
||||
React.ComponentRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-sm font-semibold", className)}
|
||||
style={{ color: "var(--text-1)" }}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
export const DialogDescription = forwardRef<
|
||||
React.ComponentRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm", className)}
|
||||
style={{ color: "var(--text-3)" }}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
42
packages/web/src/components/ui/input.tsx
Normal file
42
packages/web/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { forwardRef } from "react";
|
||||
|
||||
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(({ className, ...props }, ref) => (
|
||||
<input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex w-full rounded-lg px-3 py-2 text-sm transition-all outline-none",
|
||||
"[background:var(--surface)] [color:var(--text-1)]",
|
||||
"[border:1px_solid_var(--border-2)]",
|
||||
"placeholder:[color:var(--text-4)]",
|
||||
"focus:[border-color:var(--accent)]",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Input.displayName = "Input";
|
||||
|
||||
export type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>;
|
||||
|
||||
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<textarea
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex w-full rounded-lg px-3 py-2 text-sm transition-all outline-none resize-none",
|
||||
"[background:var(--surface)] [color:var(--text-1)]",
|
||||
"[border:1px_solid_var(--border-2)]",
|
||||
"placeholder:[color:var(--text-4)]",
|
||||
"focus:[border-color:var(--accent)]",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
Textarea.displayName = "Textarea";
|
||||
20
packages/web/src/components/ui/label.tsx
Normal file
20
packages/web/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { forwardRef } from "react";
|
||||
|
||||
export const Label = forwardRef<
|
||||
React.ComponentRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"block text-xs font-medium leading-none",
|
||||
"peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||
className,
|
||||
)}
|
||||
style={{ color: "var(--text-2)" }}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
21
packages/web/src/components/ui/separator.tsx
Normal file
21
packages/web/src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||
import { forwardRef } from "react";
|
||||
|
||||
export const Separator = forwardRef<
|
||||
React.ComponentRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(({ className, orientation = "horizontal", decorative = true, ...props }, ref) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 [background:var(--border)]",
|
||||
orientation === "horizontal" ? "h-px w-full" : "h-full w-px",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
||||
76
packages/web/src/components/ui/table.tsx
Normal file
76
packages/web/src/components/ui/table.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { forwardRef } from "react";
|
||||
|
||||
export const Table = forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div className="w-full overflow-x-auto">
|
||||
<table ref={ref} className={cn("w-full text-xs caption-bottom", className)} {...props} />
|
||||
</div>
|
||||
),
|
||||
);
|
||||
Table.displayName = "Table";
|
||||
|
||||
export const TableHeader = forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead
|
||||
ref={ref}
|
||||
className={cn("[&_tr]:border-b [&_tr]:[border-color:var(--border)]", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableHeader.displayName = "TableHeader";
|
||||
|
||||
export const TableBody = forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody ref={ref} className={cn("[&_tr:last-child]:border-0", className)} {...props} />
|
||||
));
|
||||
TableBody.displayName = "TableBody";
|
||||
|
||||
export const TableRow = forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-b transition-colors [border-color:var(--border)]",
|
||||
"hover:[background:var(--surface)]",
|
||||
"data-[state=selected]:[background:var(--surface)]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
TableRow.displayName = "TableRow";
|
||||
|
||||
export const TableHead = forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-9 px-4 text-left align-middle font-medium [background:var(--bg-3)]",
|
||||
"[color:var(--text-3)]",
|
||||
"[&:has([role=checkbox])]:pr-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableHead.displayName = "TableHead";
|
||||
|
||||
export const TableCell = forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn("px-4 py-2.5 align-middle [&:has([role=checkbox])]:pr-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableCell.displayName = "TableCell";
|
||||
31
packages/web/src/components/ui/tooltip.tsx
Normal file
31
packages/web/src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
|
||||
export const TooltipProvider = TooltipPrimitive.Provider;
|
||||
export const Tooltip = TooltipPrimitive.Root;
|
||||
export const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||
|
||||
export function TooltipContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-lg px-2.5 py-1.5 text-xs font-medium",
|
||||
"[background:var(--bg-3)] [color:var(--text-1)]",
|
||||
"[border:1px_solid_var(--border)]",
|
||||
"shadow-md",
|
||||
"animate-in fade-in-0 zoom-in-95",
|
||||
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
|
||||
"data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</TooltipPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
110
packages/web/src/components/ui/typography.tsx
Normal file
110
packages/web/src/components/ui/typography.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type AsChild<T extends React.ElementType> = {
|
||||
as?: T;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
type Props<T extends React.ElementType> = AsChild<T> &
|
||||
Omit<React.ComponentPropsWithoutRef<T>, keyof AsChild<T>>;
|
||||
|
||||
export function PageTitle<T extends React.ElementType = "h1">({
|
||||
as,
|
||||
className,
|
||||
children,
|
||||
...rest
|
||||
}: Props<T>) {
|
||||
const Tag = (as ?? "h1") as React.ElementType;
|
||||
return (
|
||||
<Tag
|
||||
className={cn("text-xl font-semibold tracking-tight", className)}
|
||||
style={{ color: "var(--text-1)" }}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
export function SectionHeading<T extends React.ElementType = "h2">({
|
||||
as,
|
||||
className,
|
||||
children,
|
||||
...rest
|
||||
}: Props<T>) {
|
||||
const Tag = (as ?? "h2") as React.ElementType;
|
||||
return (
|
||||
<Tag
|
||||
className={cn("text-sm font-medium mb-3", className)}
|
||||
style={{ color: "var(--text-1)" }}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
export function Body<T extends React.ElementType = "p">({
|
||||
as,
|
||||
className,
|
||||
children,
|
||||
...rest
|
||||
}: Props<T>) {
|
||||
const Tag = (as ?? "p") as React.ElementType;
|
||||
return (
|
||||
<Tag
|
||||
className={cn("text-sm leading-relaxed", className)}
|
||||
style={{ color: "var(--text-2)" }}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
export function Muted<T extends React.ElementType = "p">({
|
||||
as,
|
||||
className,
|
||||
children,
|
||||
...rest
|
||||
}: Props<T>) {
|
||||
const Tag = (as ?? "p") as React.ElementType;
|
||||
return (
|
||||
<Tag className={cn("text-sm", className)} style={{ color: "var(--text-3)" }} {...rest}>
|
||||
{children}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
export function Caption<T extends React.ElementType = "span">({
|
||||
as,
|
||||
className,
|
||||
children,
|
||||
...rest
|
||||
}: Props<T>) {
|
||||
const Tag = (as ?? "span") as React.ElementType;
|
||||
return (
|
||||
<Tag className={cn("text-xs", className)} style={{ color: "var(--text-4)" }} {...rest}>
|
||||
{children}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
export function MonoCaption<T extends React.ElementType = "span">({
|
||||
as,
|
||||
className,
|
||||
children,
|
||||
...rest
|
||||
}: Props<T>) {
|
||||
const Tag = (as ?? "span") as React.ElementType;
|
||||
return (
|
||||
<Tag
|
||||
className={cn("text-xs font-mono", className)}
|
||||
style={{ color: "var(--text-4)" }}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
134
packages/web/src/components/workspaces/ScheduleDreamModal.tsx
Normal file
134
packages/web/src/components/workspaces/ScheduleDreamModal.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import { FormModal } from "@/components/shared/FormModal";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Caption } from "@/components/ui/typography";
|
||||
import { COLOR } from "@/lib/constants";
|
||||
import type { UseMutationResult } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
const schema = z.object({
|
||||
observer: z.string().min(1, "Observer peer ID is required"),
|
||||
observed: z.string().optional(),
|
||||
session_id: z.string().optional(),
|
||||
});
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
workspaceId: string;
|
||||
onClose: () => void;
|
||||
mutation: UseMutationResult<
|
||||
void,
|
||||
Error,
|
||||
{ observer: string; observed?: string | null; dream_type: "omni"; session_id?: string | null }
|
||||
>;
|
||||
}
|
||||
|
||||
export function ScheduleDreamModal({ open, onClose, mutation }: Props) {
|
||||
const [observer, setObserver] = useState("");
|
||||
const [observed, setObserved] = useState("");
|
||||
const [sessionId, setSessionId] = useState("");
|
||||
const [validationError, setValidationError] = useState("");
|
||||
|
||||
const reset = () => {
|
||||
setObserver("");
|
||||
setObserved("");
|
||||
setSessionId("");
|
||||
setValidationError("");
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.SyntheticEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const result = schema.safeParse({
|
||||
observer,
|
||||
observed: observed || undefined,
|
||||
session_id: sessionId || undefined,
|
||||
});
|
||||
if (!result.success) {
|
||||
setValidationError(result.error.errors[0].message);
|
||||
return;
|
||||
}
|
||||
await mutation.mutateAsync({
|
||||
observer: result.data.observer,
|
||||
observed: result.data.observed ?? null,
|
||||
dream_type: "omni",
|
||||
session_id: result.data.session_id ?? null,
|
||||
});
|
||||
reset();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<FormModal
|
||||
open={open}
|
||||
title="Schedule Dream"
|
||||
onClose={() => {
|
||||
reset();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<Label className="mb-1.5">
|
||||
Observer peer ID <span style={{ color: COLOR.destructive }}>*</span>
|
||||
</Label>
|
||||
<Input
|
||||
value={observer}
|
||||
onChange={(e) => {
|
||||
setObserver(e.target.value);
|
||||
setValidationError("");
|
||||
}}
|
||||
placeholder="peer_id"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1.5">
|
||||
Observed peer ID <Caption as="span"> (optional, defaults to observer)</Caption>
|
||||
</Label>
|
||||
<Input
|
||||
value={observed}
|
||||
onChange={(e) => setObserved(e.target.value)}
|
||||
placeholder="peer_id"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1.5">
|
||||
Session ID <Caption as="span"> (optional)</Caption>
|
||||
</Label>
|
||||
<Input
|
||||
value={sessionId}
|
||||
onChange={(e) => setSessionId(e.target.value)}
|
||||
placeholder="session_id"
|
||||
/>
|
||||
</div>
|
||||
{validationError && (
|
||||
<Caption as="p" style={{ color: COLOR.destructive }}>
|
||||
{validationError}
|
||||
</Caption>
|
||||
)}
|
||||
{mutation.error && (
|
||||
<Caption as="p" style={{ color: COLOR.destructive }}>
|
||||
{mutation.error.message}
|
||||
</Caption>
|
||||
)}
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="surface"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
reset();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="accent" size="sm" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? "Scheduling..." : "Schedule"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</FormModal>
|
||||
);
|
||||
}
|
||||
217
packages/web/src/components/workspaces/WebhookManager.tsx
Normal file
217
packages/web/src/components/workspaces/WebhookManager.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
import { useCreateWebhook, useDeleteWebhook, useTestWebhook, useWebhooks } from "@/api/queries";
|
||||
import { ConfirmDialog } from "@/components/shared/ConfirmDialog";
|
||||
import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
||||
import { PageLoader } from "@/components/shared/LoadingSpinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Body, Muted, PageTitle, SectionHeading } from "@/components/ui/typography";
|
||||
import { COLOR } from "@/lib/constants";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { open } from "@tauri-apps/plugin-shell";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { ArrowLeft, ExternalLink, Plus, Trash2, Webhook, Zap } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
const urlSchema = z.string().url("Must be a valid URL");
|
||||
|
||||
interface Props {
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export function WebhookManager({ workspaceId }: Props) {
|
||||
const { data: webhooks, isLoading, error } = useWebhooks(workspaceId);
|
||||
const createWebhook = useCreateWebhook(workspaceId);
|
||||
const deleteWebhook = useDeleteWebhook(workspaceId);
|
||||
const testWebhook = useTestWebhook(workspaceId);
|
||||
|
||||
const [url, setUrl] = useState("");
|
||||
const [urlError, setUrlError] = useState("");
|
||||
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
||||
const [testResult, setTestResult] = useState<string | null>(null);
|
||||
|
||||
const handleCreate = async (e: React.SyntheticEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const result = urlSchema.safeParse(url);
|
||||
if (!result.success) {
|
||||
setUrlError(result.error.errors[0].message);
|
||||
return;
|
||||
}
|
||||
await createWebhook.mutateAsync(url);
|
||||
setUrl("");
|
||||
setUrlError("");
|
||||
};
|
||||
|
||||
const handleTest = async () => {
|
||||
const data = await testWebhook.mutateAsync();
|
||||
setTestResult(JSON.stringify(data, null, 2));
|
||||
setTimeout(() => setTestResult(null), 5000);
|
||||
};
|
||||
|
||||
const list = Array.isArray(webhooks) ? webhooks : [];
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }}>
|
||||
<Link
|
||||
to="/workspaces/$workspaceId"
|
||||
params={{ workspaceId } as never}
|
||||
className="inline-flex items-center gap-1.5 text-xs mb-4 transition-colors"
|
||||
style={{ color: "var(--text-3)" }}
|
||||
>
|
||||
<ArrowLeft className="w-3 h-3" strokeWidth={1.5} />
|
||||
{workspaceId}
|
||||
</Link>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Webhook className="w-5 h-5" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
|
||||
<PageTitle>Webhooks</PageTitle>
|
||||
</div>
|
||||
<Button variant="accent" size="sm" onClick={handleTest} disabled={testWebhook.isPending}>
|
||||
<Zap className="w-3.5 h-3.5" strokeWidth={2} />
|
||||
{testWebhook.isPending ? "Firing..." : "Test emit"}
|
||||
</Button>
|
||||
</div>
|
||||
<Body className="leading-none">Event webhook endpoints for this workspace</Body>
|
||||
</motion.div>
|
||||
|
||||
<div className="mt-8 space-y-4">
|
||||
<ErrorAlert error={error instanceof Error ? error : null} />
|
||||
{isLoading && <PageLoader />}
|
||||
|
||||
{/* Add webhook form */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="rounded-xl p-5 theme-card"
|
||||
>
|
||||
<SectionHeading>
|
||||
<Plus className="w-3.5 h-3.5 inline mr-1.5" strokeWidth={2} />
|
||||
Add endpoint
|
||||
</SectionHeading>
|
||||
<form onSubmit={handleCreate} className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
value={url}
|
||||
onChange={(e) => {
|
||||
setUrl(e.target.value);
|
||||
setUrlError("");
|
||||
}}
|
||||
placeholder="https://your-server.com/webhook"
|
||||
/>
|
||||
{urlError && (
|
||||
<p className="text-xs mt-1" style={{ color: COLOR.destructive }}>
|
||||
{urlError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button type="submit" variant="accent" disabled={createWebhook.isPending}>
|
||||
{createWebhook.isPending ? "Adding..." : "Add"}
|
||||
</Button>
|
||||
</form>
|
||||
</motion.div>
|
||||
|
||||
{/* Test result */}
|
||||
<AnimatePresence>
|
||||
{testResult && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="rounded-xl p-4 text-xs font-mono overflow-auto"
|
||||
style={{
|
||||
background: COLOR.successDim,
|
||||
border: `1px solid ${COLOR.successBorder}`,
|
||||
color: COLOR.success,
|
||||
}}
|
||||
>
|
||||
{testResult}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Webhook list */}
|
||||
{!isLoading && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="rounded-xl theme-card overflow-hidden"
|
||||
>
|
||||
{list.length === 0 ? (
|
||||
<div className="p-8 text-center">
|
||||
<Webhook
|
||||
className="w-8 h-8 mx-auto mb-2 opacity-20"
|
||||
style={{ color: "var(--text-3)" }}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<Muted>No webhook endpoints yet.</Muted>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y" style={{ borderColor: "var(--border)" }}>
|
||||
{list.map((wh, i) => (
|
||||
<motion.div
|
||||
key={(wh as { id: string }).id}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: i * 0.04 }}
|
||||
className="flex items-center justify-between px-5 py-3 gap-4"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="text-xs font-mono truncate"
|
||||
style={{ color: "var(--accent-text)" }}
|
||||
>
|
||||
{(wh as { url: string }).url}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void open((wh as { url: string }).url)}
|
||||
className="flex-shrink-0"
|
||||
style={{
|
||||
color: "var(--text-4)",
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-xs font-mono" style={{ color: "var(--text-4)" }}>
|
||||
{(wh as { id: string }).id}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setDeleteTarget((wh as { id: string }).id)}
|
||||
aria-label="Delete webhook"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(deleteTarget)}
|
||||
title="Delete webhook"
|
||||
description="This endpoint will stop receiving events immediately."
|
||||
confirmLabel="Delete"
|
||||
onConfirm={async () => {
|
||||
if (deleteTarget) await deleteWebhook.mutateAsync(deleteTarget);
|
||||
setDeleteTarget(null);
|
||||
}}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
loading={deleteWebhook.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
342
packages/web/src/components/workspaces/WorkspaceDetail.tsx
Normal file
342
packages/web/src/components/workspaces/WorkspaceDetail.tsx
Normal file
@@ -0,0 +1,342 @@
|
||||
import { useDeleteWorkspace, useQueueStatus, useScheduleDream, useWorkspace } from "@/api/queries";
|
||||
import { ConfirmDialog } from "@/components/shared/ConfirmDialog";
|
||||
import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
||||
import { JsonViewer } from "@/components/shared/JsonViewer";
|
||||
import { PageLoader } from "@/components/shared/LoadingSpinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Body, Caption, PageTitle, SectionHeading } from "@/components/ui/typography";
|
||||
import { ScheduleDreamModal } from "@/components/workspaces/ScheduleDreamModal";
|
||||
import { COLOR } from "@/lib/constants";
|
||||
import { Link, useNavigate, useParams } from "@tanstack/react-router";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Boxes,
|
||||
ChevronDown,
|
||||
CircleDot,
|
||||
Lightbulb,
|
||||
MessageSquare,
|
||||
Trash2,
|
||||
Users,
|
||||
Webhook,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
const NAV_SECTIONS = [
|
||||
{
|
||||
label: "Peers",
|
||||
icon: Users,
|
||||
to: "peers" as const,
|
||||
description: "Browse peer identities and memory",
|
||||
},
|
||||
{
|
||||
label: "Sessions",
|
||||
icon: MessageSquare,
|
||||
to: "sessions" as const,
|
||||
description: "View conversation sessions",
|
||||
},
|
||||
{
|
||||
label: "Conclusions",
|
||||
icon: Lightbulb,
|
||||
to: "conclusions" as const,
|
||||
description: "Browse memory conclusions",
|
||||
},
|
||||
{
|
||||
label: "Webhooks",
|
||||
icon: Webhook,
|
||||
to: "webhooks" as const,
|
||||
description: "Manage event webhooks",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function WorkspaceDetail() {
|
||||
const { workspaceId } = useParams({ strict: false }) as { workspaceId: string };
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data: workspace, isLoading, error } = useWorkspace(workspaceId);
|
||||
const { data: queue } = useQueueStatus(workspaceId);
|
||||
|
||||
const deleteWorkspace = useDeleteWorkspace();
|
||||
const scheduleDream = useScheduleDream(workspaceId);
|
||||
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [dreamOpen, setDreamOpen] = useState(false);
|
||||
const [sessionsExpanded, setSessionsExpanded] = useState(false);
|
||||
|
||||
const handleDelete = async () => {
|
||||
await deleteWorkspace.mutateAsync(workspaceId);
|
||||
navigate({ to: "/workspaces" as never });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-container page-container--wide">
|
||||
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }}>
|
||||
<Link
|
||||
to="/workspaces"
|
||||
className="inline-flex items-center gap-1.5 text-xs mb-4 transition-colors"
|
||||
style={{ color: "var(--text-3)" }}
|
||||
>
|
||||
<ArrowLeft className="w-3 h-3" strokeWidth={1.5} />
|
||||
Workspaces
|
||||
</Link>
|
||||
<div className="flex items-start justify-between gap-4 mb-1">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Boxes
|
||||
className="w-5 h-5 flex-shrink-0"
|
||||
style={{ color: "var(--accent)" }}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<PageTitle className="font-mono break-all">{workspaceId}</PageTitle>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<Button variant="accent" size="sm" onClick={() => setDreamOpen(true)}>
|
||||
<Zap className="w-3.5 h-3.5" strokeWidth={2} />
|
||||
Schedule Dream
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onClick={() => setConfirmDelete(true)}>
|
||||
<Trash2 className="w-3.5 h-3.5" strokeWidth={2} />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Body className="leading-none">Workspace overview</Body>
|
||||
</motion.div>
|
||||
|
||||
<div className="mt-8">
|
||||
<ErrorAlert error={error instanceof Error ? error : null} />
|
||||
{isLoading && <PageLoader />}
|
||||
|
||||
{!isLoading && workspace && (
|
||||
<div className="space-y-4">
|
||||
{/* Nav cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
{NAV_SECTIONS.map((s, i) => {
|
||||
const Icon = s.icon;
|
||||
return (
|
||||
<motion.div
|
||||
key={s.to}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.06, type: "spring", stiffness: 300, damping: 25 }}
|
||||
>
|
||||
<Link
|
||||
to={`/workspaces/$workspaceId/${s.to}` as never}
|
||||
params={{ workspaceId } as never}
|
||||
className="block rounded-xl p-5 group transition-all theme-card"
|
||||
>
|
||||
<Icon
|
||||
className="w-5 h-5 mb-3"
|
||||
style={{ color: "var(--accent)" }}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<SectionHeading className="mb-0.5">{s.label}</SectionHeading>
|
||||
<Caption as="p">{s.description}</Caption>
|
||||
</Link>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Queue status */}
|
||||
{queue && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.28 }}
|
||||
className="rounded-xl p-5 theme-card"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<SectionHeading className="mb-0">Queue Status</SectionHeading>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{queue.pending_work_units > 0 ? (
|
||||
<motion.div
|
||||
animate={{ opacity: [0.5, 1, 0.5] }}
|
||||
transition={{ duration: 1.5, repeat: Number.POSITIVE_INFINITY }}
|
||||
>
|
||||
<CircleDot
|
||||
className="w-3.5 h-3.5"
|
||||
style={{ color: COLOR.warning }}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</motion.div>
|
||||
) : (
|
||||
<CircleDot
|
||||
className="w-3.5 h-3.5"
|
||||
style={{ color: COLOR.success }}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className="text-xs font-medium"
|
||||
style={{
|
||||
color: queue.pending_work_units > 0 ? COLOR.warning : COLOR.success,
|
||||
}}
|
||||
>
|
||||
{queue.pending_work_units === 0
|
||||
? "Idle"
|
||||
: `${queue.pending_work_units} pending`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-4">
|
||||
{(
|
||||
[
|
||||
"total_work_units",
|
||||
"completed_work_units",
|
||||
"in_progress_work_units",
|
||||
"pending_work_units",
|
||||
] as const
|
||||
).map((key) => (
|
||||
<div key={key}>
|
||||
<div
|
||||
className="text-2xl font-semibold font-mono"
|
||||
style={{ color: "var(--text-1)" }}
|
||||
>
|
||||
{queue[key]}
|
||||
</div>
|
||||
<div className="text-xs capitalize mt-0.5" style={{ color: "var(--text-3)" }}>
|
||||
{key.replace(/_work_units$/, "").replace(/_/g, " ")}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Per-session breakdown */}
|
||||
{queue.sessions && Object.keys(queue.sessions).length > 0 && (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSessionsExpanded((v) => !v)}
|
||||
className="flex items-center gap-1.5 text-xs font-medium w-full text-left"
|
||||
style={{ color: "var(--text-3)" }}
|
||||
>
|
||||
<motion.div
|
||||
animate={{ rotate: sessionsExpanded ? 0 : -90 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<ChevronDown className="w-3.5 h-3.5" strokeWidth={2} />
|
||||
</motion.div>
|
||||
{Object.keys(queue.sessions).length} session
|
||||
{Object.keys(queue.sessions).length !== 1 ? "s" : ""}
|
||||
</button>
|
||||
<AnimatePresence initial={false}>
|
||||
{sessionsExpanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div
|
||||
className="mt-3 rounded-lg overflow-hidden"
|
||||
style={{ border: "1px solid var(--border)" }}
|
||||
>
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr
|
||||
style={{
|
||||
background: "var(--bg-3)",
|
||||
borderBottom: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
{["Session", "Total", "Done", "Active", "Pending"].map((h) => (
|
||||
<th
|
||||
key={h}
|
||||
className={`py-2 px-3 font-medium text-left ${h !== "Session" ? "text-right" : ""}`}
|
||||
style={{ color: "var(--text-3)" }}
|
||||
>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(queue.sessions).map(([sid, s], i) => (
|
||||
<tr
|
||||
key={sid}
|
||||
style={{
|
||||
borderTop: i > 0 ? "1px solid var(--border)" : undefined,
|
||||
}}
|
||||
>
|
||||
<td className="py-1.5 px-3">
|
||||
<Link
|
||||
to={"/workspaces/$workspaceId/sessions/$sessionId" as never}
|
||||
params={{ workspaceId, sessionId: sid } as never}
|
||||
className="font-mono truncate block max-w-[180px] hover:underline"
|
||||
style={{ color: "var(--accent-text)" }}
|
||||
>
|
||||
{sid}
|
||||
</Link>
|
||||
</td>
|
||||
<td
|
||||
className="py-1.5 px-3 text-right font-mono"
|
||||
style={{ color: "var(--text-2)" }}
|
||||
>
|
||||
{s.total_work_units}
|
||||
</td>
|
||||
<td
|
||||
className="py-1.5 px-3 text-right font-mono"
|
||||
style={{ color: COLOR.success }}
|
||||
>
|
||||
{s.completed_work_units}
|
||||
</td>
|
||||
<td
|
||||
className="py-1.5 px-3 text-right font-mono"
|
||||
style={{ color: COLOR.warning }}
|
||||
>
|
||||
{s.in_progress_work_units}
|
||||
</td>
|
||||
<td
|
||||
className="py-1.5 px-3 text-right font-mono"
|
||||
style={{ color: "var(--text-3)" }}
|
||||
>
|
||||
{s.pending_work_units}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Metadata */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.38 }}
|
||||
className="rounded-xl p-5 theme-card"
|
||||
>
|
||||
<SectionHeading>Metadata</SectionHeading>
|
||||
<JsonViewer data={workspace.metadata} />
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmDelete}
|
||||
title="Delete workspace"
|
||||
description={`This will permanently delete workspace "${workspaceId}" and all its data. This cannot be undone.`}
|
||||
confirmLabel="Delete workspace"
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => setConfirmDelete(false)}
|
||||
loading={deleteWorkspace.isPending}
|
||||
/>
|
||||
|
||||
<ScheduleDreamModal
|
||||
open={dreamOpen}
|
||||
workspaceId={workspaceId}
|
||||
onClose={() => setDreamOpen(false)}
|
||||
mutation={scheduleDream}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
156
packages/web/src/components/workspaces/WorkspaceList.tsx
Normal file
156
packages/web/src/components/workspaces/WorkspaceList.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import { useWorkspaces } from "@/api/queries";
|
||||
import type { components } from "@/api/schema.d.ts";
|
||||
import { EmptyState } from "@/components/shared/EmptyState";
|
||||
import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
||||
import { PageLoader } from "@/components/shared/LoadingSpinner";
|
||||
import { Pagination } from "@/components/shared/Pagination";
|
||||
import { SortControl, type SortDir } from "@/components/shared/SortControl";
|
||||
import { MonoCaption, Muted, PageTitle } from "@/components/ui/typography";
|
||||
import { COLOR } from "@/lib/constants";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { type Variants, motion } from "framer-motion";
|
||||
import { Boxes, ChevronRight, Clock } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
type Workspace = components["schemas"]["Workspace"];
|
||||
|
||||
const SORT_OPTIONS = [
|
||||
{ value: "created_at", label: "Newest" },
|
||||
{ value: "id", label: "ID" },
|
||||
];
|
||||
|
||||
const container: Variants = {
|
||||
hidden: { opacity: 0 },
|
||||
show: { opacity: 1, transition: { staggerChildren: 0.06 } },
|
||||
};
|
||||
const item: Variants = {
|
||||
hidden: { opacity: 0, y: 12 },
|
||||
show: { opacity: 1, y: 0, transition: { type: "spring", stiffness: 300, damping: 25 } },
|
||||
};
|
||||
|
||||
export function WorkspaceList() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [sortField, setSortField] = useState("created_at");
|
||||
const [sortDir, setSortDir] = useState<SortDir>("desc");
|
||||
const navigate = useNavigate();
|
||||
const { data, isLoading, error } = useWorkspaces(page);
|
||||
|
||||
const workspaces: Workspace[] = (data as { items?: Workspace[] } | undefined)?.items ?? [];
|
||||
const totalPages = (data as { pages?: number } | undefined)?.pages ?? 1;
|
||||
const total = (data as { total?: number } | undefined)?.total ?? 0;
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
return [...workspaces].sort((a, b) => {
|
||||
let cmp = 0;
|
||||
if (sortField === "created_at") {
|
||||
cmp = new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
|
||||
} else if (sortField === "id") {
|
||||
cmp = a.id.localeCompare(b.id);
|
||||
}
|
||||
return sortDir === "asc" ? cmp : -cmp;
|
||||
});
|
||||
}, [workspaces, sortField, sortDir]);
|
||||
|
||||
function handleSort(field: string, dir: SortDir) {
|
||||
setSortField(field);
|
||||
setSortDir(dir);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.35 }}
|
||||
className="mb-6"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Boxes className="w-5 h-5" style={{ color: COLOR.accent }} strokeWidth={1.5} />
|
||||
<PageTitle>Workspaces</PageTitle>
|
||||
{total > 0 && (
|
||||
<span
|
||||
className="ml-1 text-xs font-mono px-2 py-0.5 rounded-full"
|
||||
style={{
|
||||
background: COLOR.accentSubtle,
|
||||
color: COLOR.accentText,
|
||||
border: `1px solid ${COLOR.accentBorder}`,
|
||||
}}
|
||||
>
|
||||
{total}
|
||||
</span>
|
||||
)}
|
||||
<div className="ml-auto">
|
||||
<SortControl
|
||||
options={SORT_OPTIONS}
|
||||
field={sortField}
|
||||
dir={sortDir}
|
||||
onChange={handleSort}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Muted>All workspaces in your Honcho instance</Muted>
|
||||
</motion.div>
|
||||
|
||||
<ErrorAlert error={error instanceof Error ? error : null} />
|
||||
{isLoading && <PageLoader />}
|
||||
|
||||
{!isLoading && workspaces.length === 0 && (
|
||||
<EmptyState
|
||||
icon={Boxes}
|
||||
title="No workspaces found"
|
||||
description="No workspaces exist yet in this Honcho instance."
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isLoading && sorted.length > 0 && (
|
||||
<>
|
||||
<motion.div variants={container} initial="hidden" animate="show" className="space-y-2">
|
||||
{sorted.map((ws) => (
|
||||
<motion.button
|
||||
key={ws.id}
|
||||
variants={item}
|
||||
onClick={() =>
|
||||
navigate({
|
||||
to: "/workspaces/$workspaceId",
|
||||
params: { workspaceId: ws.id } as never,
|
||||
})
|
||||
}
|
||||
className="w-full text-left rounded-xl px-5 py-4 group transition-all"
|
||||
style={{
|
||||
background: COLOR.cardBaseBg,
|
||||
border: `1px solid ${COLOR.cardBaseBorder}`,
|
||||
}}
|
||||
whileHover={{
|
||||
background: COLOR.accentDimHover,
|
||||
borderColor: COLOR.accentBorder,
|
||||
x: 2,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span
|
||||
className="font-mono text-sm font-medium"
|
||||
style={{ color: COLOR.accentSoft }}
|
||||
>
|
||||
{ws.id}
|
||||
</span>
|
||||
<ChevronRight
|
||||
className="w-4 h-4 opacity-30 group-hover:opacity-70 transition-opacity"
|
||||
style={{ color: COLOR.accent }}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</div>
|
||||
{ws.created_at && (
|
||||
<div className="flex items-center gap-1.5 mt-2">
|
||||
<Clock className="w-3 h-3" style={{ color: COLOR.dimIcon }} strokeWidth={1.5} />
|
||||
<MonoCaption>{new Date(ws.created_at).toLocaleString()}</MonoCaption>
|
||||
</div>
|
||||
)}
|
||||
</motion.button>
|
||||
))}
|
||||
</motion.div>
|
||||
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
packages/web/src/hooks/useTheme.ts
Normal file
16
packages/web/src/hooks/useTheme.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { type Theme, applyTheme, getStoredTheme } from "@/lib/theme";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function useTheme() {
|
||||
const [theme, setTheme] = useState<Theme>(() => getStoredTheme());
|
||||
|
||||
useEffect(() => {
|
||||
applyTheme(theme);
|
||||
}, [theme]);
|
||||
|
||||
function toggle() {
|
||||
setTheme((t) => (t === "dark" ? "light" : "dark"));
|
||||
}
|
||||
|
||||
return { theme, toggle };
|
||||
}
|
||||
196
packages/web/src/index.css
Normal file
196
packages/web/src/index.css
Normal file
@@ -0,0 +1,196 @@
|
||||
@import "tailwindcss";
|
||||
@import "@fontsource/dm-mono/400.css";
|
||||
@import "@fontsource/dm-mono/500.css";
|
||||
@import "@fontsource/dm-sans/400.css";
|
||||
@import "@fontsource/dm-sans/500.css";
|
||||
@import "@fontsource/dm-sans/600.css";
|
||||
|
||||
/* ─── Tailwind v4 theme bridge ─── */
|
||||
@theme inline {
|
||||
--color-background: var(--bg);
|
||||
--color-foreground: var(--text-1);
|
||||
--color-card: var(--bg-2);
|
||||
--color-card-foreground: var(--text-1);
|
||||
--color-muted: var(--bg-3);
|
||||
--color-muted-foreground: var(--text-3);
|
||||
--color-primary: var(--accent);
|
||||
--color-primary-foreground: #ffffff;
|
||||
--color-secondary: var(--bg-3);
|
||||
--color-secondary-foreground: var(--text-2);
|
||||
--color-border: var(--border);
|
||||
--color-ring: var(--accent);
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
--radius-xl: 16px;
|
||||
}
|
||||
|
||||
/* ─── Theme tokens ─── */
|
||||
:root,
|
||||
[data-theme="dark"] {
|
||||
--bg: #0c0c10;
|
||||
--bg-2: #111118;
|
||||
--bg-3: #1a1a24;
|
||||
--surface: rgba(255, 255, 255, 0.03);
|
||||
--border: rgba(255, 255, 255, 0.08);
|
||||
--border-2: rgba(255, 255, 255, 0.13);
|
||||
--text-1: #e8e8f4;
|
||||
--text-2: #94a3b8;
|
||||
--text-3: #64748b;
|
||||
--text-4: #475569;
|
||||
--accent: #6366f1;
|
||||
--accent-dim: rgba(99, 102, 241, 0.15);
|
||||
--accent-border: rgba(99, 102, 241, 0.35);
|
||||
--accent-text: #a5b4fc;
|
||||
--sidebar-bg: linear-gradient(180deg, #111118 0%, #0e0e15 100%);
|
||||
--grid-line: rgba(99, 102, 241, 0.03);
|
||||
--glow: rgba(79, 70, 229, 0.08);
|
||||
--scrollbar: rgba(99, 102, 241, 0.2);
|
||||
--card-hover: rgba(99, 102, 241, 0.06);
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
--bg: #f8f8fc;
|
||||
--bg-2: #ffffff;
|
||||
--bg-3: #f0f0f8;
|
||||
--surface: rgba(0, 0, 0, 0.02);
|
||||
--border: rgba(0, 0, 0, 0.08);
|
||||
--border-2: rgba(0, 0, 0, 0.14);
|
||||
--text-1: #1a1a2e;
|
||||
--text-2: #374151;
|
||||
--text-3: #6b7280;
|
||||
--text-4: #9ca3af;
|
||||
--accent: #4f46e5;
|
||||
--accent-dim: rgba(79, 70, 229, 0.08);
|
||||
--accent-border: rgba(79, 70, 229, 0.25);
|
||||
--accent-text: #4f46e5;
|
||||
--sidebar-bg: linear-gradient(180deg, #ffffff 0%, #f4f4fc 100%);
|
||||
--grid-line: rgba(79, 70, 229, 0.04);
|
||||
--glow: rgba(79, 70, 229, 0.06);
|
||||
--scrollbar: rgba(79, 70, 229, 0.2);
|
||||
--card-hover: rgba(79, 70, 229, 0.04);
|
||||
}
|
||||
|
||||
/* ─── Base ─── */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
font-family: "DM Sans", system-ui, sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text-1);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
transition: background 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
#root {
|
||||
height: 100svh;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ─── Grid background overlay ─── */
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-image: linear-gradient(var(--grid-line) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
|
||||
background-size: 32px 32px;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* ─── Glow ─── */
|
||||
body::after {
|
||||
content: "";
|
||||
position: fixed;
|
||||
top: -20%;
|
||||
left: -10%;
|
||||
width: 60%;
|
||||
height: 70%;
|
||||
background: radial-gradient(ellipse at top left, var(--glow) 0%, transparent 70%);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* ─── Scrollbar ─── */
|
||||
::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--scrollbar);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--accent);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* ─── Focus ─── */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ─── Utility classes using theme tokens ─── */
|
||||
.theme-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.theme-card:hover {
|
||||
background: var(--card-hover);
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
|
||||
.theme-input {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-2);
|
||||
color: var(--text-1);
|
||||
border-radius: 10px;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.theme-input:focus {
|
||||
border-color: var(--accent);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* ─── Responsive container ─── */
|
||||
/* Base: padding + centering only. Width is a CSS variable so modifiers cascade. */
|
||||
.page-container {
|
||||
--page-max-width: 56rem;
|
||||
padding: 2rem;
|
||||
max-width: var(--page-max-width);
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Detail pages — WorkspaceDetail, SessionDetail, WebhookManager */
|
||||
.page-container--wide {
|
||||
--page-max-width: 72rem;
|
||||
}
|
||||
|
||||
/* Dashboard and full-grid pages — Dashboard, PeerDetail */
|
||||
.page-container--xl {
|
||||
--page-max-width: 88rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.page-container {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
64
packages/web/src/lib/config.ts
Normal file
64
packages/web/src/lib/config.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { httpFetch } from "@/lib/http";
|
||||
import { z } from "zod";
|
||||
|
||||
const CONFIG_KEY = "openconcho:config";
|
||||
|
||||
export const configSchema = z.object({
|
||||
baseUrl: z.string().url("Must be a valid URL"),
|
||||
token: z.string().optional().default(""),
|
||||
});
|
||||
|
||||
export type Config = z.infer<typeof configSchema>;
|
||||
|
||||
export function loadConfig(): Config | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(CONFIG_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw);
|
||||
return configSchema.parse(parsed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveConfig(config: Config): void {
|
||||
localStorage.setItem(CONFIG_KEY, JSON.stringify(config));
|
||||
}
|
||||
|
||||
export function clearConfig(): void {
|
||||
localStorage.removeItem(CONFIG_KEY);
|
||||
}
|
||||
|
||||
export type HealthStatus = "ok" | "auth-required" | "unreachable" | "checking";
|
||||
|
||||
export async function checkConnection(
|
||||
baseUrl: string,
|
||||
token?: string,
|
||||
): Promise<{
|
||||
status: HealthStatus;
|
||||
message: string;
|
||||
}> {
|
||||
try {
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
|
||||
const res = await httpFetch(`${baseUrl}/v3/workspaces/list`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({}),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
|
||||
if (res.ok) return { status: "ok", message: "Connected successfully" };
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
return { status: "auth-required", message: "Authentication required — provide an API token" };
|
||||
}
|
||||
return { status: "unreachable", message: `Server returned ${res.status}` };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Unknown error";
|
||||
if (msg.includes("AbortError") || msg.includes("timeout")) {
|
||||
return { status: "unreachable", message: "Connection timed out" };
|
||||
}
|
||||
return { status: "unreachable", message: `Cannot reach server: ${msg}` };
|
||||
}
|
||||
}
|
||||
43
packages/web/src/lib/constants.ts
Normal file
43
packages/web/src/lib/constants.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
// Semantic color tokens for inline styles.
|
||||
// CSS variables (var(--text-1) etc.) handle theme-aware colors.
|
||||
// These constants are for fixed semantic states that don't invert with theme.
|
||||
|
||||
export const COLOR = {
|
||||
// Status
|
||||
success: "#34d399",
|
||||
successDim: "rgba(52,211,153,0.08)",
|
||||
successBorder: "rgba(52,211,153,0.2)",
|
||||
|
||||
warning: "#f59e0b",
|
||||
warningDim: "rgba(245,158,11,0.08)",
|
||||
warningBorder: "rgba(245,158,11,0.2)",
|
||||
|
||||
destructive: "#f87171",
|
||||
destructiveDim: "rgba(239,68,68,0.08)",
|
||||
destructiveBorder: "rgba(239,68,68,0.2)",
|
||||
|
||||
// Accent (indigo — matches --accent CSS var)
|
||||
accent: "#6366f1",
|
||||
accentText: "#818cf8",
|
||||
accentSoft: "#c7d2fe",
|
||||
accentDim: "rgba(99,102,241,0.08)",
|
||||
accentDimHover: "rgba(99,102,241,0.06)",
|
||||
accentSubtle: "rgba(99,102,241,0.1)",
|
||||
accentMuted: "rgba(99,102,241,0.6)",
|
||||
accentGlow: "rgba(99,102,241,0.4)",
|
||||
accentBorder: "rgba(99,102,241,0.2)",
|
||||
accentBorderStrong: "rgba(99,102,241,0.15)",
|
||||
accentSpinnerTrack: "rgba(99,102,241,0.15)",
|
||||
|
||||
// Neutral dim (slate-300 at opacity)
|
||||
dimText: "rgba(148,163,184,0.5)",
|
||||
dimIcon: "rgba(148,163,184,0.3)",
|
||||
|
||||
// Error detail text
|
||||
destructiveMuted: "rgba(248,113,113,0.6)",
|
||||
destructiveBorderStrong: "rgba(239,68,68,0.25)",
|
||||
|
||||
// Framer-motion hover card base state (inline only — CSS vars can't be animated)
|
||||
cardBaseBg: "rgba(255,255,255,0.02)",
|
||||
cardBaseBorder: "rgba(255,255,255,0.06)",
|
||||
} as const;
|
||||
12
packages/web/src/lib/http.ts
Normal file
12
packages/web/src/lib/http.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
|
||||
|
||||
// Route fetch through Rust (reqwest) when running in Tauri — bypasses WebView CORS enforcement.
|
||||
// Falls back to native browser fetch during plain web dev (`pnpm dev:web`).
|
||||
const isTauri = Boolean(
|
||||
typeof window !== "undefined" &&
|
||||
(window as unknown as Record<string, unknown>).__TAURI_INTERNALS__,
|
||||
);
|
||||
|
||||
export const httpFetch: typeof globalThis.fetch = isTauri
|
||||
? (tauriFetch as typeof globalThis.fetch)
|
||||
: globalThis.fetch;
|
||||
14
packages/web/src/lib/theme.ts
Normal file
14
packages/web/src/lib/theme.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
const THEME_KEY = "openconcho:theme";
|
||||
|
||||
export type Theme = "dark" | "light";
|
||||
|
||||
export function getStoredTheme(): Theme {
|
||||
const stored = localStorage.getItem(THEME_KEY) as Theme | null;
|
||||
if (stored === "dark" || stored === "light") return stored;
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
}
|
||||
|
||||
export function applyTheme(theme: Theme): void {
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
localStorage.setItem(THEME_KEY, theme);
|
||||
}
|
||||
16
packages/web/src/lib/utils.ts
Normal file
16
packages/web/src/lib/utils.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
const _compact = new Intl.NumberFormat(undefined, {
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 1,
|
||||
});
|
||||
|
||||
export function formatCount(n: number): string {
|
||||
if (n < 1_000) return String(n);
|
||||
return _compact.format(n);
|
||||
}
|
||||
38
packages/web/src/main.tsx
Normal file
38
packages/web/src/main.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { RouterProvider, createRouter } from "@tanstack/react-router";
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { routeTree } from "./routeTree.gen";
|
||||
import "./index.css";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30_000,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
defaultPreload: "intent",
|
||||
scrollRestoration: true,
|
||||
});
|
||||
|
||||
declare module "@tanstack/react-router" {
|
||||
interface Register {
|
||||
router: typeof router;
|
||||
}
|
||||
}
|
||||
|
||||
const root = document.getElementById("root");
|
||||
if (!root) throw new Error("Missing #root element");
|
||||
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
282
packages/web/src/routeTree.gen.ts
Normal file
282
packages/web/src/routeTree.gen.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
/* eslint-disable */
|
||||
|
||||
// @ts-nocheck
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
|
||||
// This file was automatically generated by TanStack Router.
|
||||
// You should NOT make any changes in this file as it will be overwritten.
|
||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as WorkspacesRouteImport } from './routes/workspaces'
|
||||
import { Route as SettingsRouteImport } from './routes/settings'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as WorkspacesWorkspaceIdRouteImport } from './routes/workspaces_.$workspaceId'
|
||||
import { Route as WorkspacesWorkspaceIdWebhooksRouteImport } from './routes/workspaces_.$workspaceId_.webhooks'
|
||||
import { Route as WorkspacesWorkspaceIdSessionsRouteImport } from './routes/workspaces_.$workspaceId_.sessions'
|
||||
import { Route as WorkspacesWorkspaceIdPeersRouteImport } from './routes/workspaces_.$workspaceId_.peers'
|
||||
import { Route as WorkspacesWorkspaceIdConclusionsRouteImport } from './routes/workspaces_.$workspaceId_.conclusions'
|
||||
import { Route as WorkspacesWorkspaceIdSessionsSessionIdRouteImport } from './routes/workspaces_.$workspaceId_.sessions_.$sessionId'
|
||||
import { Route as WorkspacesWorkspaceIdPeersPeerIdRouteImport } from './routes/workspaces_.$workspaceId_.peers_.$peerId'
|
||||
import { Route as WorkspacesWorkspaceIdPeersPeerIdChatRouteImport } from './routes/workspaces_.$workspaceId_.peers_.$peerId_.chat'
|
||||
|
||||
const WorkspacesRoute = WorkspacesRouteImport.update({
|
||||
id: '/workspaces',
|
||||
path: '/workspaces',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const SettingsRoute = SettingsRouteImport.update({
|
||||
id: '/settings',
|
||||
path: '/settings',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const IndexRoute = IndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const WorkspacesWorkspaceIdRoute = WorkspacesWorkspaceIdRouteImport.update({
|
||||
id: '/workspaces_/$workspaceId',
|
||||
path: '/workspaces/$workspaceId',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const WorkspacesWorkspaceIdWebhooksRoute =
|
||||
WorkspacesWorkspaceIdWebhooksRouteImport.update({
|
||||
id: '/workspaces_/$workspaceId_/webhooks',
|
||||
path: '/workspaces/$workspaceId/webhooks',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const WorkspacesWorkspaceIdSessionsRoute =
|
||||
WorkspacesWorkspaceIdSessionsRouteImport.update({
|
||||
id: '/workspaces_/$workspaceId_/sessions',
|
||||
path: '/workspaces/$workspaceId/sessions',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const WorkspacesWorkspaceIdPeersRoute =
|
||||
WorkspacesWorkspaceIdPeersRouteImport.update({
|
||||
id: '/workspaces_/$workspaceId_/peers',
|
||||
path: '/workspaces/$workspaceId/peers',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const WorkspacesWorkspaceIdConclusionsRoute =
|
||||
WorkspacesWorkspaceIdConclusionsRouteImport.update({
|
||||
id: '/workspaces_/$workspaceId_/conclusions',
|
||||
path: '/workspaces/$workspaceId/conclusions',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const WorkspacesWorkspaceIdSessionsSessionIdRoute =
|
||||
WorkspacesWorkspaceIdSessionsSessionIdRouteImport.update({
|
||||
id: '/workspaces_/$workspaceId_/sessions_/$sessionId',
|
||||
path: '/workspaces/$workspaceId/sessions/$sessionId',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const WorkspacesWorkspaceIdPeersPeerIdRoute =
|
||||
WorkspacesWorkspaceIdPeersPeerIdRouteImport.update({
|
||||
id: '/workspaces_/$workspaceId_/peers_/$peerId',
|
||||
path: '/workspaces/$workspaceId/peers/$peerId',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const WorkspacesWorkspaceIdPeersPeerIdChatRoute =
|
||||
WorkspacesWorkspaceIdPeersPeerIdChatRouteImport.update({
|
||||
id: '/workspaces_/$workspaceId_/peers_/$peerId_/chat',
|
||||
path: '/workspaces/$workspaceId/peers/$peerId/chat',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/settings': typeof SettingsRoute
|
||||
'/workspaces': typeof WorkspacesRoute
|
||||
'/workspaces/$workspaceId': typeof WorkspacesWorkspaceIdRoute
|
||||
'/workspaces/$workspaceId/conclusions': typeof WorkspacesWorkspaceIdConclusionsRoute
|
||||
'/workspaces/$workspaceId/peers': typeof WorkspacesWorkspaceIdPeersRoute
|
||||
'/workspaces/$workspaceId/sessions': typeof WorkspacesWorkspaceIdSessionsRoute
|
||||
'/workspaces/$workspaceId/webhooks': typeof WorkspacesWorkspaceIdWebhooksRoute
|
||||
'/workspaces/$workspaceId/peers/$peerId': typeof WorkspacesWorkspaceIdPeersPeerIdRoute
|
||||
'/workspaces/$workspaceId/sessions/$sessionId': typeof WorkspacesWorkspaceIdSessionsSessionIdRoute
|
||||
'/workspaces/$workspaceId/peers/$peerId/chat': typeof WorkspacesWorkspaceIdPeersPeerIdChatRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/settings': typeof SettingsRoute
|
||||
'/workspaces': typeof WorkspacesRoute
|
||||
'/workspaces/$workspaceId': typeof WorkspacesWorkspaceIdRoute
|
||||
'/workspaces/$workspaceId/conclusions': typeof WorkspacesWorkspaceIdConclusionsRoute
|
||||
'/workspaces/$workspaceId/peers': typeof WorkspacesWorkspaceIdPeersRoute
|
||||
'/workspaces/$workspaceId/sessions': typeof WorkspacesWorkspaceIdSessionsRoute
|
||||
'/workspaces/$workspaceId/webhooks': typeof WorkspacesWorkspaceIdWebhooksRoute
|
||||
'/workspaces/$workspaceId/peers/$peerId': typeof WorkspacesWorkspaceIdPeersPeerIdRoute
|
||||
'/workspaces/$workspaceId/sessions/$sessionId': typeof WorkspacesWorkspaceIdSessionsSessionIdRoute
|
||||
'/workspaces/$workspaceId/peers/$peerId/chat': typeof WorkspacesWorkspaceIdPeersPeerIdChatRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/settings': typeof SettingsRoute
|
||||
'/workspaces': typeof WorkspacesRoute
|
||||
'/workspaces_/$workspaceId': typeof WorkspacesWorkspaceIdRoute
|
||||
'/workspaces_/$workspaceId_/conclusions': typeof WorkspacesWorkspaceIdConclusionsRoute
|
||||
'/workspaces_/$workspaceId_/peers': typeof WorkspacesWorkspaceIdPeersRoute
|
||||
'/workspaces_/$workspaceId_/sessions': typeof WorkspacesWorkspaceIdSessionsRoute
|
||||
'/workspaces_/$workspaceId_/webhooks': typeof WorkspacesWorkspaceIdWebhooksRoute
|
||||
'/workspaces_/$workspaceId_/peers_/$peerId': typeof WorkspacesWorkspaceIdPeersPeerIdRoute
|
||||
'/workspaces_/$workspaceId_/sessions_/$sessionId': typeof WorkspacesWorkspaceIdSessionsSessionIdRoute
|
||||
'/workspaces_/$workspaceId_/peers_/$peerId_/chat': typeof WorkspacesWorkspaceIdPeersPeerIdChatRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/settings'
|
||||
| '/workspaces'
|
||||
| '/workspaces/$workspaceId'
|
||||
| '/workspaces/$workspaceId/conclusions'
|
||||
| '/workspaces/$workspaceId/peers'
|
||||
| '/workspaces/$workspaceId/sessions'
|
||||
| '/workspaces/$workspaceId/webhooks'
|
||||
| '/workspaces/$workspaceId/peers/$peerId'
|
||||
| '/workspaces/$workspaceId/sessions/$sessionId'
|
||||
| '/workspaces/$workspaceId/peers/$peerId/chat'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/'
|
||||
| '/settings'
|
||||
| '/workspaces'
|
||||
| '/workspaces/$workspaceId'
|
||||
| '/workspaces/$workspaceId/conclusions'
|
||||
| '/workspaces/$workspaceId/peers'
|
||||
| '/workspaces/$workspaceId/sessions'
|
||||
| '/workspaces/$workspaceId/webhooks'
|
||||
| '/workspaces/$workspaceId/peers/$peerId'
|
||||
| '/workspaces/$workspaceId/sessions/$sessionId'
|
||||
| '/workspaces/$workspaceId/peers/$peerId/chat'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
| '/settings'
|
||||
| '/workspaces'
|
||||
| '/workspaces_/$workspaceId'
|
||||
| '/workspaces_/$workspaceId_/conclusions'
|
||||
| '/workspaces_/$workspaceId_/peers'
|
||||
| '/workspaces_/$workspaceId_/sessions'
|
||||
| '/workspaces_/$workspaceId_/webhooks'
|
||||
| '/workspaces_/$workspaceId_/peers_/$peerId'
|
||||
| '/workspaces_/$workspaceId_/sessions_/$sessionId'
|
||||
| '/workspaces_/$workspaceId_/peers_/$peerId_/chat'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
SettingsRoute: typeof SettingsRoute
|
||||
WorkspacesRoute: typeof WorkspacesRoute
|
||||
WorkspacesWorkspaceIdRoute: typeof WorkspacesWorkspaceIdRoute
|
||||
WorkspacesWorkspaceIdConclusionsRoute: typeof WorkspacesWorkspaceIdConclusionsRoute
|
||||
WorkspacesWorkspaceIdPeersRoute: typeof WorkspacesWorkspaceIdPeersRoute
|
||||
WorkspacesWorkspaceIdSessionsRoute: typeof WorkspacesWorkspaceIdSessionsRoute
|
||||
WorkspacesWorkspaceIdWebhooksRoute: typeof WorkspacesWorkspaceIdWebhooksRoute
|
||||
WorkspacesWorkspaceIdPeersPeerIdRoute: typeof WorkspacesWorkspaceIdPeersPeerIdRoute
|
||||
WorkspacesWorkspaceIdSessionsSessionIdRoute: typeof WorkspacesWorkspaceIdSessionsSessionIdRoute
|
||||
WorkspacesWorkspaceIdPeersPeerIdChatRoute: typeof WorkspacesWorkspaceIdPeersPeerIdChatRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/workspaces': {
|
||||
id: '/workspaces'
|
||||
path: '/workspaces'
|
||||
fullPath: '/workspaces'
|
||||
preLoaderRoute: typeof WorkspacesRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/settings': {
|
||||
id: '/settings'
|
||||
path: '/settings'
|
||||
fullPath: '/settings'
|
||||
preLoaderRoute: typeof SettingsRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/': {
|
||||
id: '/'
|
||||
path: '/'
|
||||
fullPath: '/'
|
||||
preLoaderRoute: typeof IndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/workspaces_/$workspaceId': {
|
||||
id: '/workspaces_/$workspaceId'
|
||||
path: '/workspaces/$workspaceId'
|
||||
fullPath: '/workspaces/$workspaceId'
|
||||
preLoaderRoute: typeof WorkspacesWorkspaceIdRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/workspaces_/$workspaceId_/webhooks': {
|
||||
id: '/workspaces_/$workspaceId_/webhooks'
|
||||
path: '/workspaces/$workspaceId/webhooks'
|
||||
fullPath: '/workspaces/$workspaceId/webhooks'
|
||||
preLoaderRoute: typeof WorkspacesWorkspaceIdWebhooksRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/workspaces_/$workspaceId_/sessions': {
|
||||
id: '/workspaces_/$workspaceId_/sessions'
|
||||
path: '/workspaces/$workspaceId/sessions'
|
||||
fullPath: '/workspaces/$workspaceId/sessions'
|
||||
preLoaderRoute: typeof WorkspacesWorkspaceIdSessionsRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/workspaces_/$workspaceId_/peers': {
|
||||
id: '/workspaces_/$workspaceId_/peers'
|
||||
path: '/workspaces/$workspaceId/peers'
|
||||
fullPath: '/workspaces/$workspaceId/peers'
|
||||
preLoaderRoute: typeof WorkspacesWorkspaceIdPeersRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/workspaces_/$workspaceId_/conclusions': {
|
||||
id: '/workspaces_/$workspaceId_/conclusions'
|
||||
path: '/workspaces/$workspaceId/conclusions'
|
||||
fullPath: '/workspaces/$workspaceId/conclusions'
|
||||
preLoaderRoute: typeof WorkspacesWorkspaceIdConclusionsRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/workspaces_/$workspaceId_/sessions_/$sessionId': {
|
||||
id: '/workspaces_/$workspaceId_/sessions_/$sessionId'
|
||||
path: '/workspaces/$workspaceId/sessions/$sessionId'
|
||||
fullPath: '/workspaces/$workspaceId/sessions/$sessionId'
|
||||
preLoaderRoute: typeof WorkspacesWorkspaceIdSessionsSessionIdRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/workspaces_/$workspaceId_/peers_/$peerId': {
|
||||
id: '/workspaces_/$workspaceId_/peers_/$peerId'
|
||||
path: '/workspaces/$workspaceId/peers/$peerId'
|
||||
fullPath: '/workspaces/$workspaceId/peers/$peerId'
|
||||
preLoaderRoute: typeof WorkspacesWorkspaceIdPeersPeerIdRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/workspaces_/$workspaceId_/peers_/$peerId_/chat': {
|
||||
id: '/workspaces_/$workspaceId_/peers_/$peerId_/chat'
|
||||
path: '/workspaces/$workspaceId/peers/$peerId/chat'
|
||||
fullPath: '/workspaces/$workspaceId/peers/$peerId/chat'
|
||||
preLoaderRoute: typeof WorkspacesWorkspaceIdPeersPeerIdChatRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
SettingsRoute: SettingsRoute,
|
||||
WorkspacesRoute: WorkspacesRoute,
|
||||
WorkspacesWorkspaceIdRoute: WorkspacesWorkspaceIdRoute,
|
||||
WorkspacesWorkspaceIdConclusionsRoute: WorkspacesWorkspaceIdConclusionsRoute,
|
||||
WorkspacesWorkspaceIdPeersRoute: WorkspacesWorkspaceIdPeersRoute,
|
||||
WorkspacesWorkspaceIdSessionsRoute: WorkspacesWorkspaceIdSessionsRoute,
|
||||
WorkspacesWorkspaceIdWebhooksRoute: WorkspacesWorkspaceIdWebhooksRoute,
|
||||
WorkspacesWorkspaceIdPeersPeerIdRoute: WorkspacesWorkspaceIdPeersPeerIdRoute,
|
||||
WorkspacesWorkspaceIdSessionsSessionIdRoute:
|
||||
WorkspacesWorkspaceIdSessionsSessionIdRoute,
|
||||
WorkspacesWorkspaceIdPeersPeerIdChatRoute:
|
||||
WorkspacesWorkspaceIdPeersPeerIdChatRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
._addFileTypes<FileRouteTypes>()
|
||||
43
packages/web/src/routes/__root.tsx
Normal file
43
packages/web/src/routes/__root.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Sidebar } from "@/components/layout/Sidebar";
|
||||
import { loadConfig } from "@/lib/config";
|
||||
import { applyTheme, getStoredTheme } from "@/lib/theme";
|
||||
import { Outlet, createRootRoute, useRouter } from "@tanstack/react-router";
|
||||
import { useEffect } from "react";
|
||||
|
||||
function RootLayout() {
|
||||
const config = loadConfig();
|
||||
const router = useRouter();
|
||||
const isSettings = router.state.location.pathname === "/settings";
|
||||
|
||||
useEffect(() => {
|
||||
applyTheme(getStoredTheme());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!config && !isSettings) {
|
||||
router.navigate({ to: "/settings" as never });
|
||||
}
|
||||
}, [config, isSettings, router]);
|
||||
|
||||
if (isSettings) {
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
if (!config) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-screen w-full overflow-hidden"
|
||||
style={{ background: "var(--bg)", position: "relative", zIndex: 1 }}
|
||||
>
|
||||
<Sidebar />
|
||||
<main className="flex-1 overflow-auto" style={{ position: "relative", zIndex: 1 }}>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const Route = createRootRoute({
|
||||
component: RootLayout,
|
||||
});
|
||||
6
packages/web/src/routes/index.tsx
Normal file
6
packages/web/src/routes/index.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { Dashboard } from "@/components/dashboard/Dashboard";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
component: Dashboard,
|
||||
});
|
||||
48
packages/web/src/routes/settings.tsx
Normal file
48
packages/web/src/routes/settings.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { SettingsForm } from "@/components/settings/SettingsForm";
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { motion } from "framer-motion";
|
||||
import { Brain } from "lucide-react";
|
||||
|
||||
export const Route = createFileRoute("/settings")({
|
||||
component: SettingsPage,
|
||||
});
|
||||
|
||||
function SettingsPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex-1 flex items-center justify-center p-4 overflow-auto"
|
||||
style={{ background: "var(--bg)" }}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ type: "spring", stiffness: 260, damping: 24 }}
|
||||
className="w-full max-w-md"
|
||||
>
|
||||
<div className="mb-8 text-center">
|
||||
<div
|
||||
className="w-14 h-14 rounded-2xl flex items-center justify-center mx-auto mb-4"
|
||||
style={{
|
||||
background: "linear-gradient(135deg, #4f46e5, #7c3aed)",
|
||||
boxShadow: "0 0 32px rgba(99,102,241,0.35)",
|
||||
}}
|
||||
>
|
||||
<Brain className="w-7 h-7 text-white" strokeWidth={2} />
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight" style={{ color: "var(--text-1)" }}>
|
||||
Honcho UI
|
||||
</h1>
|
||||
<p className="text-sm mt-1" style={{ color: "var(--text-3)" }}>
|
||||
Connect to your self-hosted Honcho instance
|
||||
</p>
|
||||
</div>
|
||||
<SettingsForm onSaved={() => navigate({ to: "/" as never })} />
|
||||
<p className="text-xs text-center mt-4" style={{ color: "var(--text-4)" }}>
|
||||
Connection details are stored locally on this device only
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
6
packages/web/src/routes/workspaces.tsx
Normal file
6
packages/web/src/routes/workspaces.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { WorkspaceList } from "@/components/workspaces/WorkspaceList";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/workspaces")({
|
||||
component: WorkspaceList,
|
||||
});
|
||||
6
packages/web/src/routes/workspaces_.$workspaceId.tsx
Normal file
6
packages/web/src/routes/workspaces_.$workspaceId.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { WorkspaceDetail } from "@/components/workspaces/WorkspaceDetail";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/workspaces_/$workspaceId")({
|
||||
component: WorkspaceDetail,
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import { ConclusionBrowser } from "@/components/conclusions/ConclusionBrowser";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/workspaces_/$workspaceId_/conclusions")({
|
||||
component: ConclusionBrowser,
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import { PeerList } from "@/components/peers/PeerList";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/workspaces_/$workspaceId_/peers")({
|
||||
component: PeerList,
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import { PeerDetail } from "@/components/peers/PeerDetail";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/workspaces_/$workspaceId_/peers_/$peerId")({
|
||||
component: PeerDetail,
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import { ChatPage } from "@/components/chat/ChatPage";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/workspaces_/$workspaceId_/peers_/$peerId_/chat")({
|
||||
component: ChatPage,
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import { SessionList } from "@/components/sessions/SessionList";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/workspaces_/$workspaceId_/sessions")({
|
||||
component: SessionList,
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import { SessionDetail } from "@/components/sessions/SessionDetail";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/workspaces_/$workspaceId_/sessions_/$sessionId")({
|
||||
component: SessionDetail,
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { WebhookManager } from "@/components/workspaces/WebhookManager";
|
||||
import { createFileRoute, useParams } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/workspaces_/$workspaceId_/webhooks")({
|
||||
component: WebhookManagerPage,
|
||||
});
|
||||
|
||||
function WebhookManagerPage() {
|
||||
const { workspaceId } = useParams({ strict: false }) as { workspaceId: string };
|
||||
return <WebhookManager workspaceId={workspaceId} />;
|
||||
}
|
||||
Reference in New Issue
Block a user