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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -35,3 +35,4 @@ dist-ssr
|
||||
|
||||
# TypeScript build info
|
||||
*.tsbuildinfo
|
||||
.tanstack/
|
||||
|
||||
31
src/api/keys.ts
Normal file
31
src/api/keys.ts
Normal 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,
|
||||
};
|
||||
@@ -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!;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 “{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)",
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)" }}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)" }}
|
||||
>
|
||||
|
||||
124
src/components/shared/ConfirmDialog.tsx
Normal file
124
src/components/shared/ConfirmDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
72
src/components/shared/FormModal.tsx
Normal file
72
src/components/shared/FormModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
94
src/components/shared/InlineEditor.tsx
Normal file
94
src/components/shared/InlineEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
116
src/components/workspaces/ScheduleDreamModal.tsx
Normal file
116
src/components/workspaces/ScheduleDreamModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
229
src/components/workspaces/WebhookManager.tsx
Normal file
229
src/components/workspaces/WebhookManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
11
src/routes/workspaces_.$workspaceId_.webhooks.tsx
Normal file
11
src/routes/workspaces_.$workspaceId_.webhooks.tsx
Normal 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} />;
|
||||
}
|
||||
Reference in New Issue
Block a user