feat: wire all remaining API endpoints

- src/api/keys.ts: QK singleton for all 20+ query key patterns
- src/api/queries.ts: rewritten with QK + full mutation coverage:
  deleteWorkspace, updateWorkspace, scheduleDream,
  updatePeer, setPeerCard, searchPeer,
  deleteSession, updateSession, cloneSession, searchSession,
  createMessages, updateMessage,
  useSessionPeers, addPeersToSession, setSessionPeers,
  removePeersFromSession, setPeerConfig,
  createConclusion, deleteConclusion,
  useWebhooks, createWebhook, deleteWebhook, testWebhook,
  createKey

Shared primitives (just-in-time):
- ConfirmDialog: animated modal with keyboard/click-out dismiss
- FormModal: reusable modal shell
- InlineEditor: click-to-edit with commit/cancel

New surfaces:
- WorkspaceDetail: delete + schedule dream buttons
- ScheduleDreamModal: Zod-validated observer/session form
- WebhookManager: full CRUD + test emit (new /webhooks route)
- SessionDetail: delete, clone, session search, peer membership tab
- PeerDetail: editable peer card, semantic search tab
- ConclusionBrowser: create conclusion modal (Zod), per-row delete
This commit is contained in:
Offending Commit
2026-04-24 11:25:19 -05:00
parent 88565eaf1a
commit 45e0183439
16 changed files with 1934 additions and 188 deletions

1
.gitignore vendored
View File

@@ -35,3 +35,4 @@ dist-ssr
# TypeScript build info
*.tsbuildinfo
.tanstack/

31
src/api/keys.ts Normal file
View File

@@ -0,0 +1,31 @@
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) => ["peer-representation", wsId, pId] 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) =>
["conclusions", wsId, filters, page, size] 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,
};

View File

@@ -1,46 +1,103 @@
import { useQuery, useMutation, 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: ["workspaces", page, pageSize],
queryKey: QK.workspaces(page, pageSize),
queryFn: async () => {
const { data, error } = await client.current.POST("/v3/workspaces/list", {
params: { query: { page, page_size: pageSize } },
body: {},
});
if (error) throw new Error(JSON.stringify(error));
return data;
if (error) err(error);
return data!;
},
});
}
export function useWorkspace(workspaceId: string) {
return useQuery({
queryKey: ["workspace", workspaceId],
queryKey: QK.workspace(workspaceId),
queryFn: async () => {
const { data, error } = await client.current.POST("/v3/workspaces", {
body: { id: workspaceId, metadata: {} },
});
if (error) throw new Error(JSON.stringify(error));
return data;
if (error) err(error);
return data!;
},
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 },
);
if (error) err(error);
return data!;
},
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: ["queue-status", workspaceId],
queryKey: QK.queueStatus(workspaceId),
queryFn: async () => {
const { data, error } = await client.current.GET(
"/v3/workspaces/{workspace_id}/queue/status",
{ params: { path: { workspace_id: workspaceId } } },
);
if (error) throw new Error(JSON.stringify(error));
return data;
if (error) err(error);
return data!;
},
enabled: Boolean(workspaceId),
refetchInterval: 10_000,
@@ -49,7 +106,7 @@ export function useQueueStatus(workspaceId: string) {
export function useSearchWorkspace(workspaceId: string, query: string, enabled = false) {
return useQuery({
queryKey: ["workspace-search", workspaceId, query],
queryKey: QK.workspaceSearch(workspaceId, query),
queryFn: async () => {
const { data, error } = await client.current.POST(
"/v3/workspaces/{workspace_id}/search",
@@ -58,8 +115,8 @@ export function useSearchWorkspace(workspaceId: string, query: string, enabled =
body: { query, limit: 20 },
},
);
if (error) throw new Error(JSON.stringify(error));
return data;
if (error) err(error);
return data!;
},
enabled: enabled && Boolean(workspaceId) && Boolean(query),
});
@@ -69,7 +126,7 @@ export function useSearchWorkspace(workspaceId: string, query: string, enabled =
export function usePeers(workspaceId: string, page = 1, pageSize = 20) {
return useQuery({
queryKey: ["peers", workspaceId, page, pageSize],
queryKey: QK.peers(workspaceId, page, pageSize),
queryFn: async () => {
const { data, error } = await client.current.POST(
"/v3/workspaces/{workspace_id}/peers/list",
@@ -78,8 +135,8 @@ export function usePeers(workspaceId: string, page = 1, pageSize = 20) {
body: {},
},
);
if (error) throw new Error(JSON.stringify(error));
return data;
if (error) err(error);
return data!;
},
enabled: Boolean(workspaceId),
});
@@ -87,7 +144,7 @@ export function usePeers(workspaceId: string, page = 1, pageSize = 20) {
export function usePeer(workspaceId: string, peerId: string) {
return useQuery({
queryKey: ["peer", workspaceId, peerId],
queryKey: QK.peer(workspaceId, peerId),
queryFn: async () => {
const { data, error } = await client.current.POST(
"/v3/workspaces/{workspace_id}/peers",
@@ -96,16 +153,34 @@ export function usePeer(workspaceId: string, peerId: string) {
body: { id: peerId, metadata: {} },
},
);
if (error) throw new Error(JSON.stringify(error));
return data;
if (error) err(error);
return data!;
},
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 },
);
if (error) err(error);
return data!;
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["peer", workspaceId, peerId] });
qc.invalidateQueries({ queryKey: ["peers", workspaceId] });
},
});
}
export function usePeerRepresentation(workspaceId: string, peerId: string) {
return useQuery({
queryKey: ["peer-representation", workspaceId, peerId],
queryKey: QK.peerRepresentation(workspaceId, peerId),
queryFn: async () => {
const { data, error } = await client.current.POST(
"/v3/workspaces/{workspace_id}/peers/{peer_id}/representation",
@@ -114,8 +189,8 @@ export function usePeerRepresentation(workspaceId: string, peerId: string) {
body: { max_conclusions: 20 },
},
);
if (error) throw new Error(JSON.stringify(error));
return data;
if (error) err(error);
return data!;
},
enabled: Boolean(workspaceId) && Boolean(peerId),
});
@@ -123,33 +198,49 @@ export function usePeerRepresentation(workspaceId: string, peerId: string) {
export function usePeerCard(workspaceId: string, peerId: string) {
return useQuery({
queryKey: ["peer-card", workspaceId, peerId],
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 } },
},
{ params: { path: { workspace_id: workspaceId, peer_id: peerId } } },
);
if (error) throw new Error(JSON.stringify(error));
return data;
if (error) err(error);
return data!;
},
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 },
},
);
if (error) err(error);
return data!;
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.peerCard(workspaceId, peerId) });
},
});
}
export function usePeerContext(workspaceId: string, peerId: string) {
return useQuery({
queryKey: ["peer-context", workspaceId, peerId],
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 } },
},
{ params: { path: { workspace_id: workspaceId, peer_id: peerId } } },
);
if (error) throw new Error(JSON.stringify(error));
return data;
if (error) err(error);
return data!;
},
enabled: Boolean(workspaceId) && Boolean(peerId),
});
@@ -157,7 +248,7 @@ export function usePeerContext(workspaceId: string, peerId: string) {
export function usePeerSessions(workspaceId: string, peerId: string, page = 1, pageSize = 20) {
return useQuery({
queryKey: ["peer-sessions", workspaceId, peerId, page, pageSize],
queryKey: QK.peerSessions(workspaceId, peerId, page, pageSize),
queryFn: async () => {
const { data, error } = await client.current.POST(
"/v3/workspaces/{workspace_id}/peers/{peer_id}/sessions",
@@ -169,15 +260,31 @@ export function usePeerSessions(workspaceId: string, peerId: string, page = 1, p
body: {},
},
);
if (error) throw new Error(JSON.stringify(error));
return data;
if (error) err(error);
return data!;
},
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 },
},
);
if (error) err(error);
return data!;
},
});
}
export function useChat(workspaceId: string, peerId: string) {
const queryClient = useQueryClient();
const qc = useQueryClient();
return useMutation({
mutationFn: async (message: string) => {
const { data, error } = await client.current.POST(
@@ -187,11 +294,11 @@ export function useChat(workspaceId: string, peerId: string) {
body: { query: message, stream: false, reasoning_level: "low" },
},
);
if (error) throw new Error(JSON.stringify(error));
return data;
if (error) err(error);
return data!;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["peer-context", workspaceId, peerId] });
qc.invalidateQueries({ queryKey: ["peer-context", workspaceId, peerId] });
},
});
}
@@ -200,7 +307,7 @@ export function useChat(workspaceId: string, peerId: string) {
export function useSessions(workspaceId: string, page = 1, pageSize = 20) {
return useQuery({
queryKey: ["sessions", workspaceId, page, pageSize],
queryKey: QK.sessions(workspaceId, page, pageSize),
queryFn: async () => {
const { data, error } = await client.current.POST(
"/v3/workspaces/{workspace_id}/sessions/list",
@@ -212,13 +319,80 @@ export function useSessions(workspaceId: string, page = 1, pageSize = 20) {
body: {},
},
);
if (error) throw new Error(JSON.stringify(error));
return data;
if (error) err(error);
return data!;
},
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 },
);
if (error) err(error);
return data!;
},
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 } } },
);
if (error) err(error);
return data!;
},
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 },
},
);
if (error) err(error);
return data!;
},
});
}
export function useSessionMessages(
workspaceId: string,
sessionId: string,
@@ -226,7 +400,7 @@ export function useSessionMessages(
pageSize = 50,
) {
return useQuery({
queryKey: ["session-messages", workspaceId, sessionId, page, pageSize],
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",
@@ -238,25 +412,204 @@ export function useSessionMessages(
body: {},
},
);
if (error) throw new Error(JSON.stringify(error));
return data;
if (error) err(error);
return data!;
},
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 },
},
);
if (error) err(error);
return data!;
},
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,
},
);
if (error) err(error);
return data!;
},
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 } } },
);
if (error) err(error);
return data!;
},
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,
},
);
if (error) err(error);
return data!;
},
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,
},
);
if (error) err(error);
return data!;
},
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 },
},
},
);
if (error) err(error);
return data!;
},
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,
},
);
if (error) err(error);
return data!;
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.peerConfig(workspaceId, sessionId, peerId) });
},
});
}
// ─── Session summaries & context ──────────────────────────────────────────────
export function useSessionSummaries(workspaceId: string, sessionId: string) {
return useQuery({
queryKey: ["session-summaries", workspaceId, sessionId],
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 } },
},
{ params: { path: { workspace_id: workspaceId, session_id: sessionId } } },
);
if (error) throw new Error(JSON.stringify(error));
return data;
if (error) err(error);
return data!;
},
enabled: Boolean(workspaceId) && Boolean(sessionId),
});
@@ -264,16 +617,14 @@ export function useSessionSummaries(workspaceId: string, sessionId: string) {
export function useSessionContext(workspaceId: string, sessionId: string) {
return useQuery({
queryKey: ["session-context", workspaceId, sessionId],
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 } },
},
{ params: { path: { workspace_id: workspaceId, session_id: sessionId } } },
);
if (error) throw new Error(JSON.stringify(error));
return data;
if (error) err(error);
return data!;
},
enabled: Boolean(workspaceId) && Boolean(sessionId),
});
@@ -288,7 +639,7 @@ export function useConclusions(
pageSize = 20,
) {
return useQuery({
queryKey: ["conclusions", workspaceId, filters, page, pageSize],
queryKey: QK.conclusions(workspaceId, filters, page, pageSize),
queryFn: async () => {
const { data, error } = await client.current.POST(
"/v3/workspaces/{workspace_id}/conclusions/list",
@@ -300,8 +651,8 @@ export function useConclusions(
body: filters,
},
);
if (error) throw new Error(JSON.stringify(error));
return data;
if (error) err(error);
return data!;
},
enabled: Boolean(workspaceId),
});
@@ -314,7 +665,7 @@ export function useQueryConclusions(
enabled = false,
) {
return useQuery({
queryKey: ["conclusions-query", workspaceId, query, filters],
queryKey: QK.conclusionsQuery(workspaceId, query, filters),
queryFn: async () => {
const { data, error } = await client.current.POST(
"/v3/workspaces/{workspace_id}/conclusions/query",
@@ -323,9 +674,137 @@ export function useQueryConclusions(
body: { query, top_k: 10, ...filters },
},
);
if (error) throw new Error(JSON.stringify(error));
return data;
if (error) err(error);
return data!;
},
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] },
},
);
if (error) err(error);
return data!;
},
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 } } },
);
if (error) err(error);
return data!;
},
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 },
},
);
if (error) err(error);
return data!;
},
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 } } },
);
if (error) err(error);
return data!;
},
});
}
// ─── Keys ─────────────────────────────────────────────────────────────────────
export function useCreateKey() {
return useMutation({
mutationFn: async () => {
const { data, error } = await client.current.POST("/v3/keys", {});
if (error) err(error);
return data!;
},
});
}

View File

@@ -1,16 +1,31 @@
import { useState } from "react";
import { Link, useParams } from "@tanstack/react-router";
import { motion, AnimatePresence } from "framer-motion";
import { Lightbulb, Search, X, Clock, ArrowLeft, Eye } from "lucide-react";
import { useConclusions, useQueryConclusions } from "@/api/queries";
import { z } from "zod";
import { Lightbulb, Search, X, Clock, ArrowLeft, Eye, Plus, Trash2 } from "lucide-react";
import {
useConclusions,
useQueryConclusions,
useCreateConclusion,
useDeleteConclusion,
} from "@/api/queries";
import { PageLoader } from "@/components/shared/LoadingSpinner";
import { ErrorAlert } from "@/components/shared/ErrorAlert";
import { Pagination } from "@/components/shared/Pagination";
import { EmptyState } from "@/components/shared/EmptyState";
import { FormModal } from "@/components/shared/FormModal";
import { ConfirmDialog } from "@/components/shared/ConfirmDialog";
import type { components } from "@/api/schema.d.ts";
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 itemVariants = {
hidden: { opacity: 0, y: 8 },
show: (i: number) => ({
@@ -25,6 +40,8 @@ export function ConclusionBrowser() {
const [page, setPage] = useState(1);
const [searchQuery, setSearchQuery] = useState("");
const [activeSearch, setActiveSearch] = useState("");
const [createOpen, setCreateOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
const { data, isLoading, error } = useConclusions(workspaceId, {}, page);
const { data: searchResults, isLoading: searchLoading } = useQueryConclusions(
@@ -33,6 +50,8 @@ export function ConclusionBrowser() {
{},
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;
@@ -50,39 +69,47 @@ export function ConclusionBrowser() {
return (
<div className="p-8 max-w-3xl mx-auto">
<motion.div
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
className="mb-8"
>
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }} className="mb-8">
<Link
to="/workspaces/$workspaceId"
params={{ workspaceId }}
params={{ workspaceId } as never}
className="inline-flex items-center gap-1.5 text-xs mb-4 transition-colors"
style={{ color: "rgba(148,163,184,0.5)" }}
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: "#6366f1" }} strokeWidth={1.5} />
<h1 className="text-xl font-semibold tracking-tight" style={{ color: "#e4e4f0" }}>
<Lightbulb className="w-5 h-5" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
<h1 className="text-xl font-semibold tracking-tight" style={{ color: "var(--text-1)" }}>
Conclusions
</h1>
{total > 0 && !activeSearch && (
<span
className="ml-auto text-xs font-mono px-2 py-0.5 rounded-full"
style={{
background: "rgba(99,102,241,0.1)",
color: "#818cf8",
border: "1px solid rgba(99,102,241,0.2)",
background: "var(--accent-dim)",
color: "var(--accent-text)",
border: "1px solid var(--accent-border)",
}}
>
{total}
</span>
)}
<button
onClick={() => setCreateOpen(true)}
className="ml-auto flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
style={{
background: "var(--accent-dim)",
border: "1px solid var(--accent-border)",
color: "var(--accent-text)",
}}
>
<Plus className="w-3.5 h-3.5" strokeWidth={2} />
New
</button>
</div>
<p className="text-sm mt-0.5" style={{ color: "rgba(148,163,184,0.6)" }}>
<p className="text-sm mt-0.5" style={{ color: "var(--text-3)" }}>
Distilled memory observations about peers
</p>
</motion.div>
@@ -92,7 +119,7 @@ export function ConclusionBrowser() {
<div className="relative flex-1">
<Search
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4"
style={{ color: "rgba(148,163,184,0.4)" }}
style={{ color: "var(--text-4)" }}
strokeWidth={1.5}
/>
<input
@@ -100,24 +127,13 @@ export function ConclusionBrowser() {
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Semantic search across conclusions..."
className="w-full rounded-xl pl-9 pr-4 py-2.5 text-sm font-mono outline-none transition-all"
style={{
background: "rgba(255,255,255,0.03)",
border: "1px solid rgba(255,255,255,0.08)",
color: "#e4e4f0",
}}
onFocus={(e) => {
e.target.style.borderColor = "rgba(99,102,241,0.4)";
}}
onBlur={(e) => {
e.target.style.borderColor = "rgba(255,255,255,0.08)";
}}
className="theme-input w-full rounded-xl pl-9 pr-4 py-2.5 text-sm font-mono"
/>
</div>
<button
type="submit"
className="px-4 py-2.5 rounded-xl text-sm font-medium transition-all"
style={{ background: "#4f46e5", color: "#fff" }}
style={{ background: "var(--accent)", color: "#fff" }}
>
Search
</button>
@@ -131,9 +147,9 @@ export function ConclusionBrowser() {
onClick={() => { setActiveSearch(""); setSearchQuery(""); }}
className="px-3 py-2.5 rounded-xl text-sm transition-all"
style={{
background: "rgba(255,255,255,0.05)",
border: "1px solid rgba(255,255,255,0.08)",
color: "rgba(148,163,184,0.7)",
background: "var(--surface)",
border: "1px solid var(--border)",
color: "var(--text-3)",
}}
>
<X className="w-4 h-4" strokeWidth={1.5} />
@@ -164,7 +180,7 @@ export function ConclusionBrowser() {
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="text-xs font-mono mb-3"
style={{ color: "rgba(148,163,184,0.4)" }}
style={{ color: "var(--text-4)" }}
>
{displayedConclusions.length} result{displayedConclusions.length !== 1 ? "s" : ""}{" "}
for &ldquo;{activeSearch}&rdquo;
@@ -178,40 +194,46 @@ export function ConclusionBrowser() {
variants={itemVariants}
initial="hidden"
animate="show"
className="rounded-xl p-5"
className="group rounded-xl p-5"
style={{
background: "rgba(255,255,255,0.02)",
border: "1px solid rgba(255,255,255,0.06)",
background: "var(--surface)",
border: "1px solid var(--border)",
}}
>
<p
className="text-sm leading-relaxed whitespace-pre-wrap"
style={{ color: "#d4d4f5" }}
>
{c.content}
</p>
<div className="flex items-start justify-between gap-3">
<p className="text-sm leading-relaxed whitespace-pre-wrap flex-1" style={{ color: "var(--text-2)" }}>
{c.content}
</p>
<button
onClick={() => setDeleteTarget(c.id)}
className="opacity-0 group-hover:opacity-100 p-1.5 rounded-lg transition-all flex-shrink-0"
style={{ color: "var(--text-4)" }}
>
<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 rgba(255,255,255,0.05)" }}
style={{ borderTop: "1px solid var(--border)" }}
>
<div className="flex items-center gap-1.5">
<Eye className="w-3 h-3" style={{ color: "rgba(148,163,184,0.35)" }} strokeWidth={1.5} />
<span className="text-xs font-mono" style={{ color: "rgba(148,163,184,0.4)" }}>
<Eye className="w-3 h-3" style={{ color: "var(--text-4)" }} strokeWidth={1.5} />
<span className="text-xs font-mono" style={{ color: "var(--text-4)" }}>
{c.observer_id}
</span>
</div>
{c.observed_id && (
<div className="flex items-center gap-1">
<span className="text-xs" style={{ color: "rgba(148,163,184,0.2)" }}></span>
<span className="text-xs font-mono" style={{ color: "rgba(148,163,184,0.4)" }}>
<span className="text-xs" style={{ color: "var(--text-4)" }}></span>
<span className="text-xs font-mono" style={{ color: "var(--text-4)" }}>
{c.observed_id}
</span>
</div>
)}
{c.created_at && (
<div className="flex items-center gap-1 ml-auto">
<Clock className="w-3 h-3" style={{ color: "rgba(148,163,184,0.25)" }} strokeWidth={1.5} />
<span className="text-xs font-mono" style={{ color: "rgba(148,163,184,0.3)" }}>
<Clock className="w-3 h-3" style={{ color: "var(--text-4)" }} strokeWidth={1.5} />
<span className="text-xs font-mono" style={{ color: "var(--text-4)" }}>
{new Date(c.created_at).toLocaleString()}
</span>
</div>
@@ -225,6 +247,136 @@ export function ConclusionBrowser() {
)}
</>
)}
<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="block text-xs font-medium mb-1" style={{ color: "var(--text-2)" }}>
{field === "observer_id" ? "Observer peer ID" : "Observed peer ID"}{" "}
<span style={{ color: "#f87171" }}>*</span>
</label>
<input
value={fields[field]}
onChange={set(field)}
placeholder="peer_id"
className="theme-input w-full text-sm px-3 py-2 rounded-lg"
/>
{validationErrors[field] && (
<p className="text-xs mt-1" style={{ color: "#f87171" }}>{validationErrors[field]}</p>
)}
</div>
))}
<div>
<label className="block text-xs font-medium mb-1" style={{ color: "var(--text-2)" }}>
Content <span style={{ color: "#f87171" }}>*</span>
</label>
<textarea
value={fields.content}
onChange={set("content")}
rows={4}
placeholder="The conclusion content…"
className="theme-input w-full text-sm px-3 py-2 rounded-lg resize-y"
/>
{validationErrors.content && (
<p className="text-xs mt-1" style={{ color: "#f87171" }}>{validationErrors.content}</p>
)}
</div>
<div>
<label className="block text-xs font-medium mb-1" style={{ color: "var(--text-2)" }}>
Session ID <span style={{ color: "var(--text-4)" }}>(optional)</span>
</label>
<input
value={fields.session_id}
onChange={set("session_id")}
placeholder="session_id"
className="theme-input w-full text-sm px-3 py-2 rounded-lg"
/>
</div>
{error && <p className="text-xs" style={{ color: "#f87171" }}>{error}</p>}
<div className="flex justify-end gap-2 pt-2">
<button
type="button"
onClick={onClose}
className="px-3 py-1.5 text-sm rounded-lg"
style={{ background: "var(--surface)", border: "1px solid var(--border)", color: "var(--text-2)" }}
>
Cancel
</button>
<button
type="submit"
disabled={loading}
className="px-3 py-1.5 text-sm rounded-lg font-medium disabled:opacity-50"
style={{ background: "var(--accent-dim)", border: "1px solid var(--accent-border)", color: "var(--accent-text)" }}
>
{loading ? "Creating..." : "Create"}
</button>
</div>
</form>
</FormModal>
);
}

View File

@@ -1,13 +1,21 @@
import { useState } from "react";
import { Link, useNavigate, useParams } from "@tanstack/react-router";
import { motion } from "framer-motion";
import { User, MessageCircle } from "lucide-react";
import { usePeer, usePeerCard, usePeerContext, usePeerRepresentation } from "@/api/queries";
import { motion, AnimatePresence } from "framer-motion";
import { User, MessageCircle, Search, Save, X } from "lucide-react";
import {
usePeer,
usePeerCard,
usePeerContext,
usePeerRepresentation,
useSetPeerCard,
useSearchPeer,
} from "@/api/queries";
import { PageLoader } from "@/components/shared/LoadingSpinner";
import { ErrorAlert } from "@/components/shared/ErrorAlert";
import { JsonViewer } from "@/components/shared/JsonViewer";
import { Badge } from "@/components/shared/Badge";
type Tab = "context" | "card" | "representation" | "metadata";
type Tab = "context" | "card" | "representation" | "metadata" | "search";
export function PeerDetail() {
const { workspaceId, peerId } = useParams({ strict: false }) as {
@@ -22,10 +30,24 @@ export function PeerDetail() {
const { data: context, isLoading: contextLoading } = usePeerContext(workspaceId, peerId);
const { data: representation, isLoading: repLoading } = usePeerRepresentation(workspaceId, peerId);
const setPeerCard = useSetPeerCard(workspaceId, peerId);
const searchPeer = useSearchPeer(workspaceId, peerId);
const [cardDraft, setCardDraft] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const cardLines: string[] =
Array.isArray((card as { peer_card?: unknown })?.peer_card)
? ((card as { peer_card: string[] }).peer_card)
: typeof card === "string"
? [card]
: [];
const tabs: Array<{ id: Tab; label: string }> = [
{ id: "context", label: "Context" },
{ id: "card", label: "Card" },
{ id: "representation", label: "Representation" },
{ id: "search", label: "Search" },
{ id: "metadata", label: "Metadata" },
];
@@ -65,10 +87,7 @@ export function PeerDetail() {
})
}
className="shrink-0 flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium transition-all"
style={{
background: "var(--accent)",
color: "#fff",
}}
style={{ background: "var(--accent)", color: "#fff" }}
>
<MessageCircle className="w-4 h-4" strokeWidth={1.5} />
Chat
@@ -82,7 +101,6 @@ export function PeerDetail() {
{!isLoading && peer && (
<>
{/* Tab bar */}
<div
className="flex gap-0.5 mb-4 p-1 rounded-xl"
style={{ background: "var(--bg-3)", border: "1px solid var(--border)" }}
@@ -107,7 +125,6 @@ export function PeerDetail() {
))}
</div>
{/* Tab content */}
<motion.div
key={tab}
initial={{ opacity: 0, y: 4 }}
@@ -127,18 +144,72 @@ export function PeerDetail() {
</>
)
)}
{tab === "card" && (
cardLoading ? <PageLoader /> : (
<>
<h2 className="text-sm font-medium mb-3" style={{ color: "var(--text-1)" }}>Peer Card</h2>
{typeof card === "string" ? (
<p className="text-sm whitespace-pre-wrap" style={{ color: "var(--text-2)" }}>{card}</p>
) : (
<JsonViewer data={card} maxHeight="400px" />
)}
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-medium" style={{ color: "var(--text-1)" }}>Peer Card</h2>
{cardDraft === null ? (
<button
onClick={() => setCardDraft(cardLines.join("\n"))}
className="text-xs px-2 py-1 rounded-lg transition-colors"
style={{ background: "var(--accent-dim)", border: "1px solid var(--accent-border)", color: "var(--accent-text)" }}
>
Edit
</button>
) : (
<div className="flex gap-1.5">
<button
onClick={() => {
setPeerCard.mutate(cardDraft.split("\n").filter(Boolean));
setCardDraft(null);
}}
disabled={setPeerCard.isPending}
className="flex items-center gap-1 text-xs px-2 py-1 rounded-lg disabled:opacity-50"
style={{ background: "var(--accent-dim)", border: "1px solid var(--accent-border)", color: "var(--accent-text)" }}
>
<Save className="w-3 h-3" strokeWidth={2} />
Save
</button>
<button
onClick={() => setCardDraft(null)}
className="flex items-center gap-1 text-xs px-2 py-1 rounded-lg"
style={{ background: "var(--surface)", border: "1px solid var(--border)", color: "var(--text-3)" }}
>
<X className="w-3 h-3" strokeWidth={2} />
</button>
</div>
)}
</div>
<AnimatePresence mode="wait">
{cardDraft !== null ? (
<motion.textarea
key="edit"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
value={cardDraft}
onChange={(e) => setCardDraft(e.target.value)}
rows={8}
className="theme-input w-full text-sm px-3 py-2 rounded-lg font-mono resize-y"
style={{ minHeight: "8rem" }}
/>
) : (
<motion.div key="view" initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
{cardLines.length > 0 ? (
<p className="text-sm whitespace-pre-wrap" style={{ color: "var(--text-2)" }}>
{cardLines.join("\n")}
</p>
) : (
<p className="text-sm" style={{ color: "var(--text-4)" }}>No card set.</p>
)}
</motion.div>
)}
</AnimatePresence>
</>
)
)}
{tab === "representation" && (
repLoading ? <PageLoader /> : (
<>
@@ -153,6 +224,64 @@ export function PeerDetail() {
</>
)
)}
{tab === "search" && (
<>
<h2 className="text-sm font-medium mb-3" style={{ color: "var(--text-1)" }}>
<Search className="w-3.5 h-3.5 inline mr-1.5" strokeWidth={2} />
Search peer messages
</h2>
<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="theme-input flex-1 text-sm px-3 py-2 rounded-lg"
/>
<button
type="submit"
disabled={searchPeer.isPending}
className="px-3 py-2 text-sm rounded-lg font-medium"
style={{ background: "var(--accent-dim)", border: "1px solid var(--accent-border)", color: "var(--accent-text)" }}
>
{searchPeer.isPending ? "…" : "Search"}
</button>
</form>
{searchPeer.data && (
<div className="space-y-3">
{(searchPeer.data as Array<{ id: string; content: string; peer_id?: string; created_at?: string }>).length === 0 ? (
<p className="text-sm" style={{ color: "var(--text-3)" }}>No results.</p>
) : (
(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 && (
<span className="text-xs" style={{ color: "var(--text-4)" }}>
{new Date(r.created_at).toLocaleString()}
</span>
)}
</div>
<p className="text-sm whitespace-pre-wrap" style={{ color: "var(--text-2)" }}>{r.content}</p>
</div>
))
)}
</div>
)}
</>
)}
{tab === "metadata" && (
<>
<h2 className="text-sm font-medium mb-3" style={{ color: "var(--text-1)" }}>Peer Metadata</h2>

View File

@@ -39,7 +39,7 @@ export function PeerList() {
>
<Link
to="/workspaces/$workspaceId"
params={{ workspaceId }}
params={{ workspaceId } as never}
className="inline-flex items-center gap-1.5 text-xs mb-4 transition-colors"
style={{ color: "rgba(148,163,184,0.5)" }}
>

View File

@@ -1,38 +1,89 @@
import { useState } from "react";
import { Link, useParams } from "@tanstack/react-router";
import { motion } from "framer-motion";
import { MessageSquare } from "lucide-react";
import { useSessionMessages, useSessionSummaries, useSessionContext } from "@/api/queries";
import { Link, useParams, useNavigate } from "@tanstack/react-router";
import { motion, AnimatePresence } from "framer-motion";
import { MessageSquare, Trash2, Copy, Search, Users, X } from "lucide-react";
import {
useSessionMessages,
useSessionSummaries,
useSessionContext,
useSessionPeers,
useDeleteSession,
useCloneSession,
useSearchSession,
useRemovePeersFromSession,
usePeers,
useAddPeersToSession,
} from "@/api/queries";
import { PageLoader } from "@/components/shared/LoadingSpinner";
import { Pagination } from "@/components/shared/Pagination";
import { Badge } from "@/components/shared/Badge";
import { JsonViewer } from "@/components/shared/JsonViewer";
import { ConfirmDialog } from "@/components/shared/ConfirmDialog";
import type { components } from "@/api/schema.d.ts";
type Message = components["schemas"]["Message"];
type Tab = "messages" | "summaries" | "context";
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 memberPeerIds = new Set(
(sessionPeers as Array<{ id?: string; peer_id?: string }> | undefined)?.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">
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }}>
@@ -46,18 +97,103 @@ export function SessionDetail() {
</Link>
</div>
<div className="flex items-center gap-2 mb-1">
<MessageSquare className="w-5 h-5" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
<h1
className="text-xl font-semibold font-mono break-all tracking-tight"
style={{ color: "var(--text-1)" }}
>
{sessionId}
</h1>
<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} />
<h1 className="text-xl font-semibold font-mono break-all tracking-tight" style={{ color: "var(--text-1)" }}>
{sessionId}
</h1>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<button
onClick={() => setSearchActive((v) => !v)}
className="p-1.5 rounded-lg transition-colors"
style={{
background: searchActive ? "var(--accent-dim)" : "var(--surface)",
border: `1px solid ${searchActive ? "var(--accent-border)" : "var(--border)"}`,
color: searchActive ? "var(--accent-text)" : "var(--text-3)",
}}
>
<Search className="w-3.5 h-3.5" strokeWidth={2} />
</button>
<button
onClick={handleClone}
disabled={cloneSession.isPending}
className="p-1.5 rounded-lg transition-colors disabled:opacity-50"
style={{
background: "var(--surface)",
border: "1px solid var(--border)",
color: "var(--text-3)",
}}
>
<Copy className="w-3.5 h-3.5" strokeWidth={2} />
</button>
<button
onClick={() => setConfirmDelete(true)}
className="p-1.5 rounded-lg transition-colors"
style={{
background: "rgba(239,68,68,0.08)",
border: "1px solid rgba(239,68,68,0.2)",
color: "#f87171",
}}
>
<Trash2 className="w-3.5 h-3.5" strokeWidth={2} />
</button>
</div>
</div>
<p className="text-sm" style={{ color: "var(--text-2)" }}>Session detail</p>
</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="theme-input flex-1 text-sm px-3 py-2 rounded-lg"
/>
<button
type="submit"
disabled={searchSession.isPending}
className="px-3 py-2 text-sm rounded-lg font-medium"
style={{ background: "var(--accent-dim)", border: "1px solid var(--accent-border)", color: "var(--accent-text)" }}
>
{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 ? (
<p className="text-sm" style={{ color: "var(--text-3)" }}>No results.</p>
) : (
(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
@@ -99,11 +235,7 @@ export function SessionDetail() {
) : (
<div className="space-y-4">
{messages.map((msg) => (
<div
key={msg.id}
className="pb-4"
style={{ borderBottom: "1px solid var(--border)" }}
>
<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"}
@@ -119,10 +251,7 @@ export function SessionDetail() {
</span>
)}
</div>
<p
className="text-sm whitespace-pre-wrap leading-relaxed"
style={{ color: "var(--text-2)" }}
>
<p className="text-sm whitespace-pre-wrap leading-relaxed" style={{ color: "var(--text-2)" }}>
{msg.content}
</p>
</div>
@@ -157,8 +286,111 @@ export function SessionDetail() {
</>
)
)}
{tab === "peers" && (
peersLoading ? <PageLoader /> : (
<SessionPeersTab
members={sessionPeers as Array<{ id?: string; peer_id?: string }> | undefined}
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>
<h2 className="text-sm font-medium mb-2" style={{ color: "var(--text-1)" }}>
<Users className="w-3.5 h-3.5 inline mr-1.5" strokeWidth={2} />
Session members ({list.length})
</h2>
{list.length === 0 ? (
<p className="text-sm" style={{ color: "var(--text-3)" }}>No peers in this session.</p>
) : (
<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
onClick={() => onRemove(id)}
disabled={removing}
className="p-1 rounded transition-colors disabled:opacity-40"
style={{ color: "var(--text-4)" }}
>
<X className="w-3 h-3" strokeWidth={2} />
</button>
</div>
);
})}
</div>
)}
</div>
{available.length > 0 && (
<div>
<h2 className="text-sm font-medium mb-2" style={{ color: "var(--text-2)" }}>
Add peer
</h2>
<div className="space-y-1 max-h-48 overflow-auto">
{available.map((p) => (
<button
key={p.id}
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>
);
}

View File

@@ -39,7 +39,7 @@ export function SessionList() {
>
<Link
to="/workspaces/$workspaceId"
params={{ workspaceId }}
params={{ workspaceId } as never}
className="inline-flex items-center gap-1.5 text-xs mb-4 transition-colors"
style={{ color: "rgba(148,163,184,0.5)" }}
>

View File

@@ -0,0 +1,124 @@
import { useEffect, useRef } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { AlertTriangle, X } 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) {
const cancelRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (open) cancelRef.current?.focus();
}, [open]);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape" && open) onCancel();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [open, onCancel]);
return (
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center p-4"
style={{ background: "rgba(0,0,0,0.6)", backdropFilter: "blur(4px)" }}
onClick={(e) => e.target === e.currentTarget && onCancel()}
>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 8 }}
transition={{ type: "spring", stiffness: 300, damping: 28 }}
className="w-full max-w-sm rounded-2xl p-6 relative"
style={{
background: "var(--bg-2)",
border: "1px solid var(--border-2)",
boxShadow: "0 24px 64px rgba(0,0,0,0.4)",
}}
>
<button
onClick={onCancel}
className="absolute top-4 right-4 p-1 rounded-lg transition-colors"
style={{ color: "var(--text-4)" }}
>
<X className="w-4 h-4" />
</button>
<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: "rgba(239,68,68,0.1)" }}
>
<AlertTriangle className="w-4 h-4" style={{ color: "#f87171" }} strokeWidth={2} />
</div>
)}
<div>
<h3
className="text-sm font-semibold mb-1"
style={{ color: "var(--text-1)" }}
>
{title}
</h3>
<p className="text-sm" style={{ color: "var(--text-3)" }}>
{description}
</p>
</div>
</div>
<div className="flex gap-2 justify-end mt-6">
<button
ref={cancelRef}
onClick={onCancel}
className="px-3 py-1.5 text-sm rounded-lg transition-colors"
style={{
background: "var(--surface)",
border: "1px solid var(--border)",
color: "var(--text-2)",
}}
>
Cancel
</button>
<button
onClick={onConfirm}
disabled={loading}
className="px-3 py-1.5 text-sm rounded-lg font-medium transition-colors disabled:opacity-50"
style={
danger
? { background: "rgba(239,68,68,0.15)", color: "#f87171", border: "1px solid rgba(239,68,68,0.3)" }
: { background: "var(--accent-dim)", color: "var(--accent-text)", border: "1px solid var(--accent-border)" }
}
>
{loading ? "..." : confirmLabel}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}

View File

@@ -0,0 +1,72 @@
import { useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { X } from "lucide-react";
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) {
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape" && open) onClose();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [open, onClose]);
return (
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center p-4"
style={{ background: "rgba(0,0,0,0.6)", backdropFilter: "blur(4px)" }}
onClick={(e) => e.target === e.currentTarget && onClose()}
>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 12 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 12 }}
transition={{ type: "spring", stiffness: 300, damping: 28 }}
className={`w-full ${maxWidth} rounded-2xl relative`}
style={{
background: "var(--bg-2)",
border: "1px solid var(--border-2)",
boxShadow: "0 24px 64px rgba(0,0,0,0.4)",
}}
>
<div
className="flex items-center justify-between px-5 py-4"
style={{ borderBottom: "1px solid var(--border)" }}
>
<h3 className="text-sm font-semibold" style={{ color: "var(--text-1)" }}>
{title}
</h3>
<button
onClick={onClose}
className="p-1 rounded-lg transition-colors"
style={{ color: "var(--text-4)" }}
>
<X className="w-4 h-4" />
</button>
</div>
<div className="p-5">{children}</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}

View File

@@ -0,0 +1,94 @@
import { useState, useRef, useEffect } from "react";
import { Pencil, Check, X } from "lucide-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
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
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
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>
);
}

View File

@@ -0,0 +1,116 @@
import { useState } from "react";
import { z } from "zod";
import type { UseMutationResult } from "@tanstack/react-query";
import { FormModal } from "@/components/shared/FormModal";
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="block text-xs font-medium mb-1.5" style={{ color: "var(--text-2)" }}>
Observer peer ID <span style={{ color: "#f87171" }}>*</span>
</label>
<input
value={observer}
onChange={(e) => { setObserver(e.target.value); setValidationError(""); }}
placeholder="peer_id"
className="theme-input w-full text-sm px-3 py-2 rounded-lg"
/>
</div>
<div>
<label className="block text-xs font-medium mb-1.5" style={{ color: "var(--text-2)" }}>
Observed peer ID <span style={{ color: "var(--text-4)" }}>(optional, defaults to observer)</span>
</label>
<input
value={observed}
onChange={(e) => setObserved(e.target.value)}
placeholder="peer_id"
className="theme-input w-full text-sm px-3 py-2 rounded-lg"
/>
</div>
<div>
<label className="block text-xs font-medium mb-1.5" style={{ color: "var(--text-2)" }}>
Session ID <span style={{ color: "var(--text-4)" }}>(optional)</span>
</label>
<input
value={sessionId}
onChange={(e) => setSessionId(e.target.value)}
placeholder="session_id"
className="theme-input w-full text-sm px-3 py-2 rounded-lg"
/>
</div>
{validationError && (
<p className="text-xs" style={{ color: "#f87171" }}>{validationError}</p>
)}
{mutation.error && (
<p className="text-xs" style={{ color: "#f87171" }}>{mutation.error.message}</p>
)}
<div className="flex justify-end gap-2 pt-2">
<button
type="button"
onClick={() => { reset(); onClose(); }}
className="px-3 py-1.5 text-sm rounded-lg"
style={{ background: "var(--surface)", border: "1px solid var(--border)", color: "var(--text-2)" }}
>
Cancel
</button>
<button
type="submit"
disabled={mutation.isPending}
className="px-3 py-1.5 text-sm rounded-lg font-medium disabled:opacity-50"
style={{ background: "var(--accent-dim)", border: "1px solid var(--accent-border)", color: "var(--accent-text)" }}
>
{mutation.isPending ? "Scheduling..." : "Schedule"}
</button>
</div>
</form>
</FormModal>
);
}

View File

@@ -0,0 +1,229 @@
import { useState } from "react";
import { Link } from "@tanstack/react-router";
import { motion, AnimatePresence } from "framer-motion";
import { z } from "zod";
import { Webhook, Trash2, Plus, ArrowLeft, Zap, ExternalLink } from "lucide-react";
import { useWebhooks, useCreateWebhook, useDeleteWebhook, useTestWebhook } from "@/api/queries";
import { PageLoader } from "@/components/shared/LoadingSpinner";
import { ErrorAlert } from "@/components/shared/ErrorAlert";
import { ConfirmDialog } from "@/components/shared/ConfirmDialog";
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} />
<h1 className="text-xl font-semibold tracking-tight" style={{ color: "var(--text-1)" }}>
Webhooks
</h1>
</div>
<button
onClick={handleTest}
disabled={testWebhook.isPending}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors disabled:opacity-50"
style={{
background: "var(--accent-dim)",
border: "1px solid var(--accent-border)",
color: "var(--accent-text)",
}}
>
<Zap className="w-3.5 h-3.5" strokeWidth={2} />
{testWebhook.isPending ? "Firing..." : "Test emit"}
</button>
</div>
<p className="text-sm" style={{ color: "var(--text-2)" }}>
Event webhook endpoints for this workspace
</p>
</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"
>
<h2 className="text-sm font-medium mb-3" style={{ color: "var(--text-1)" }}>
<Plus className="w-3.5 h-3.5 inline mr-1.5" strokeWidth={2} />
Add endpoint
</h2>
<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"
className="theme-input w-full text-sm px-3 py-2 rounded-lg"
/>
{urlError && (
<p className="text-xs mt-1" style={{ color: "#f87171" }}>{urlError}</p>
)}
</div>
<button
type="submit"
disabled={createWebhook.isPending}
className="px-3 py-2 text-sm rounded-lg font-medium disabled:opacity-50"
style={{
background: "var(--accent-dim)",
border: "1px solid var(--accent-border)",
color: "var(--accent-text)",
}}
>
{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: "rgba(52,211,153,0.06)",
border: "1px solid rgba(52,211,153,0.2)",
color: "#34d399",
}}
>
{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}
/>
<p className="text-sm" style={{ color: "var(--text-3)" }}>
No webhook endpoints yet.
</p>
</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>
<a
href={(wh as { url: string }).url}
target="_blank"
rel="noreferrer"
className="flex-shrink-0"
style={{ color: "var(--text-4)" }}
>
<ExternalLink className="w-3 h-3" strokeWidth={1.5} />
</a>
</div>
<span
className="text-xs font-mono"
style={{ color: "var(--text-4)" }}
>
{(wh as { id: string }).id}
</span>
</div>
<button
onClick={() => setDeleteTarget((wh as { id: string }).id)}
className="p-1.5 rounded-lg transition-colors flex-shrink-0"
style={{ color: "var(--text-4)" }}
>
<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>
);
}

View File

@@ -1,22 +1,44 @@
import { Link, useParams } from "@tanstack/react-router";
import { useState } from "react";
import { Link, useParams, useNavigate } from "@tanstack/react-router";
import { motion } from "framer-motion";
import { Boxes, Users, MessageSquare, Lightbulb, ArrowLeft, CircleDot } from "lucide-react";
import { useWorkspace, useQueueStatus } from "@/api/queries";
import { Boxes, Users, MessageSquare, Lightbulb, ArrowLeft, CircleDot, Trash2, Zap, Webhook } from "lucide-react";
import {
useWorkspace,
useQueueStatus,
useDeleteWorkspace,
useScheduleDream,
} from "@/api/queries";
import { PageLoader } from "@/components/shared/LoadingSpinner";
import { ErrorAlert } from "@/components/shared/ErrorAlert";
import { JsonViewer } from "@/components/shared/JsonViewer";
import { ConfirmDialog } from "@/components/shared/ConfirmDialog";
import { ScheduleDreamModal } from "@/components/workspaces/ScheduleDreamModal";
const sections = [
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 handleDelete = async () => {
await deleteWorkspace.mutateAsync(workspaceId);
navigate({ to: "/workspaces" as never });
};
return (
<div className="page-container">
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }}>
@@ -28,14 +50,42 @@ export function WorkspaceDetail() {
<ArrowLeft className="w-3 h-3" strokeWidth={1.5} />
Workspaces
</Link>
<div className="flex items-center gap-2 mb-1">
<Boxes className="w-5 h-5" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
<h1
className="text-xl font-semibold font-mono break-all tracking-tight"
style={{ color: "var(--text-1)" }}
>
{workspaceId}
</h1>
<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} />
<h1
className="text-xl font-semibold font-mono break-all tracking-tight"
style={{ color: "var(--text-1)" }}
>
{workspaceId}
</h1>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<button
onClick={() => setDreamOpen(true)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
style={{
background: "var(--accent-dim)",
border: "1px solid var(--accent-border)",
color: "var(--accent-text)",
}}
>
<Zap className="w-3.5 h-3.5" strokeWidth={2} />
Schedule Dream
</button>
<button
onClick={() => setConfirmDelete(true)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
style={{
background: "rgba(239,68,68,0.08)",
border: "1px solid rgba(239,68,68,0.2)",
color: "#f87171",
}}
>
<Trash2 className="w-3.5 h-3.5" strokeWidth={2} />
Delete
</button>
</div>
</div>
<p className="text-sm" style={{ color: "var(--text-2)" }}>Workspace overview</p>
</motion.div>
@@ -47,30 +97,23 @@ export function WorkspaceDetail() {
{!isLoading && workspace && (
<div className="space-y-4">
{/* Nav cards */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
{sections.map((s, i) => {
<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.07, type: "spring", stiffness: 300, damping: 25 }}
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}
/>
<h2
className="text-sm font-medium mb-0.5"
style={{ color: "var(--text-1)" }}
>
<Icon className="w-5 h-5 mb-3" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
<h2 className="text-sm font-medium mb-0.5" style={{ color: "var(--text-1)" }}>
{s.label}
</h2>
<p className="text-xs" style={{ color: "var(--text-3)" }}>
@@ -87,7 +130,7 @@ export function WorkspaceDetail() {
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.25 }}
transition={{ delay: 0.28 }}
className="rounded-xl p-5 theme-card"
>
<div className="flex items-center justify-between mb-4">
@@ -96,10 +139,7 @@ export function WorkspaceDetail() {
</h2>
<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: Infinity }}
>
<motion.div animate={{ opacity: [0.5, 1, 0.5] }} transition={{ duration: 1.5, repeat: Infinity }}>
<CircleDot className="w-3.5 h-3.5" style={{ color: "#f59e0b" }} strokeWidth={2} />
</motion.div>
) : (
@@ -116,10 +156,7 @@ export function WorkspaceDetail() {
<div className="grid grid-cols-2 sm:grid-cols-4 gap-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)" }}
>
<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)" }}>
@@ -135,7 +172,7 @@ export function WorkspaceDetail() {
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.35 }}
transition={{ delay: 0.38 }}
className="rounded-xl p-5 theme-card"
>
<h2 className="text-sm font-medium mb-3" style={{ color: "var(--text-1)" }}>
@@ -146,6 +183,23 @@ export function WorkspaceDetail() {
</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>
);
}

View File

@@ -13,6 +13,7 @@ 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'
@@ -40,6 +41,12 @@ const WorkspacesWorkspaceIdRoute = WorkspacesWorkspaceIdRouteImport.update({
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',
@@ -85,6 +92,7 @@ export interface FileRoutesByFullPath {
'/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
@@ -97,6 +105,7 @@ export interface FileRoutesByTo {
'/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
@@ -110,6 +119,7 @@ export interface FileRoutesById {
'/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
@@ -124,6 +134,7 @@ export interface FileRouteTypes {
| '/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'
@@ -136,6 +147,7 @@ export interface FileRouteTypes {
| '/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'
@@ -148,6 +160,7 @@ export interface FileRouteTypes {
| '/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'
@@ -161,6 +174,7 @@ export interface RootRouteChildren {
WorkspacesWorkspaceIdConclusionsRoute: typeof WorkspacesWorkspaceIdConclusionsRoute
WorkspacesWorkspaceIdPeersRoute: typeof WorkspacesWorkspaceIdPeersRoute
WorkspacesWorkspaceIdSessionsRoute: typeof WorkspacesWorkspaceIdSessionsRoute
WorkspacesWorkspaceIdWebhooksRoute: typeof WorkspacesWorkspaceIdWebhooksRoute
WorkspacesWorkspaceIdPeersPeerIdRoute: typeof WorkspacesWorkspaceIdPeersPeerIdRoute
WorkspacesWorkspaceIdSessionsSessionIdRoute: typeof WorkspacesWorkspaceIdSessionsSessionIdRoute
WorkspacesWorkspaceIdPeersPeerIdChatRoute: typeof WorkspacesWorkspaceIdPeersPeerIdChatRoute
@@ -196,6 +210,13 @@ declare module '@tanstack/react-router' {
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'
@@ -249,6 +270,7 @@ const rootRouteChildren: RootRouteChildren = {
WorkspacesWorkspaceIdConclusionsRoute: WorkspacesWorkspaceIdConclusionsRoute,
WorkspacesWorkspaceIdPeersRoute: WorkspacesWorkspaceIdPeersRoute,
WorkspacesWorkspaceIdSessionsRoute: WorkspacesWorkspaceIdSessionsRoute,
WorkspacesWorkspaceIdWebhooksRoute: WorkspacesWorkspaceIdWebhooksRoute,
WorkspacesWorkspaceIdPeersPeerIdRoute: WorkspacesWorkspaceIdPeersPeerIdRoute,
WorkspacesWorkspaceIdSessionsSessionIdRoute:
WorkspacesWorkspaceIdSessionsSessionIdRoute,

View File

@@ -0,0 +1,11 @@
import { createFileRoute, useParams } from "@tanstack/react-router";
import { WebhookManager } from "@/components/workspaces/WebhookManager";
export const Route = createFileRoute("/workspaces_/$workspaceId_/webhooks")({
component: WebhookManagerPage,
});
function WebhookManagerPage() {
const { workspaceId } = useParams({ strict: false }) as { workspaceId: string };
return <WebhookManager workspaceId={workspaceId} />;
}