From 45e01834398859d2eadcca1abe1eb213e3ff629d Mon Sep 17 00:00:00 2001 From: Offending Commit Date: Fri, 24 Apr 2026 11:25:19 -0500 Subject: [PATCH] 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 --- .gitignore | 1 + src/api/keys.ts | 31 + src/api/queries.ts | 607 ++++++++++++++++-- .../conclusions/ConclusionBrowser.tsx | 250 ++++++-- src/components/peers/PeerDetail.tsx | 161 ++++- src/components/peers/PeerList.tsx | 2 +- src/components/sessions/SessionDetail.tsx | 276 +++++++- src/components/sessions/SessionList.tsx | 2 +- src/components/shared/ConfirmDialog.tsx | 124 ++++ src/components/shared/FormModal.tsx | 72 +++ src/components/shared/InlineEditor.tsx | 94 +++ .../workspaces/ScheduleDreamModal.tsx | 116 ++++ src/components/workspaces/WebhookManager.tsx | 229 +++++++ src/components/workspaces/WorkspaceDetail.tsx | 124 +++- src/routeTree.gen.ts | 22 + .../workspaces_.$workspaceId_.webhooks.tsx | 11 + 16 files changed, 1934 insertions(+), 188 deletions(-) create mode 100644 src/api/keys.ts create mode 100644 src/components/shared/ConfirmDialog.tsx create mode 100644 src/components/shared/FormModal.tsx create mode 100644 src/components/shared/InlineEditor.tsx create mode 100644 src/components/workspaces/ScheduleDreamModal.tsx create mode 100644 src/components/workspaces/WebhookManager.tsx create mode 100644 src/routes/workspaces_.$workspaceId_.webhooks.tsx diff --git a/.gitignore b/.gitignore index a2a9c02..da6757b 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,4 @@ dist-ssr # TypeScript build info *.tsbuildinfo +.tanstack/ diff --git a/src/api/keys.ts b/src/api/keys.ts new file mode 100644 index 0000000..dcde554 --- /dev/null +++ b/src/api/keys.ts @@ -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, page: number, size: number) => + ["conclusions", wsId, filters, page, size] as const, + conclusionsQuery: (wsId: string, q: string, filters: Record) => + ["conclusions-query", wsId, q, filters] as const, + + webhooks: (wsId: string) => ["webhooks", wsId] as const, +}; diff --git a/src/api/queries.ts b/src/api/queries.ts index fd27055..282f0b6 100644 --- a/src/api/queries.ts +++ b/src/api/queries.ts @@ -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 }) => { + 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 }) => { + 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 }) => { + 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 }>, + ) => { + 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 }; + }) => { + 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) => { + 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!; + }, + }); +} diff --git a/src/components/conclusions/ConclusionBrowser.tsx b/src/components/conclusions/ConclusionBrowser.tsx index ef2396a..aca6bbc 100644 --- a/src/components/conclusions/ConclusionBrowser.tsx +++ b/src/components/conclusions/ConclusionBrowser.tsx @@ -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(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 (
- + {workspaceId}
- -

+ +

Conclusions

{total > 0 && !activeSearch && ( {total} )} +

-

+

Distilled memory observations about peers

@@ -92,7 +119,7 @@ export function ConclusionBrowser() {
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" />
@@ -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)", }} > @@ -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 “{activeSearch}” @@ -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)", }} > -

- {c.content} -

+
+

+ {c.content} +

+ +
- - + + {c.observer_id}
{c.observed_id && (
- - + + {c.observed_id}
)} {c.created_at && (
- - + + {new Date(c.created_at).toLocaleString()}
@@ -225,6 +247,136 @@ export function ConclusionBrowser() { )} )} + + setCreateOpen(false)} + onSubmit={async (values) => { + await createConclusion.mutateAsync(values); + setCreateOpen(false); + }} + loading={createConclusion.isPending} + error={createConclusion.error?.message} + /> + + { + if (deleteTarget) await deleteConclusion.mutateAsync(deleteTarget); + setDeleteTarget(null); + }} + onCancel={() => setDeleteTarget(null)} + loading={deleteConclusion.isPending} + />
); } + +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; + loading: boolean; + error?: string; +}) { + const [fields, setFields] = useState({ observer_id: "", observed_id: "", content: "", session_id: "" }); + const [validationErrors, setValidationErrors] = useState>({}); + + const set = (k: keyof typeof fields) => (e: React.ChangeEvent) => + setFields((f) => ({ ...f, [k]: e.target.value })); + + const handleSubmit = async (e: React.SyntheticEvent) => { + e.preventDefault(); + const result = createSchema.safeParse(fields); + if (!result.success) { + const errs: Record = {}; + 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 ( + +
+ {(["observer_id", "observed_id"] as const).map((field) => ( +
+ + + {validationErrors[field] && ( +

{validationErrors[field]}

+ )} +
+ ))} +
+ +