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
|
# TypeScript build info
|
||||||
*.tsbuildinfo
|
*.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 { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { client } from "./client";
|
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 ──────────────────────────────────────────────────────────────
|
// ─── Workspaces ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function useWorkspaces(page = 1, pageSize = 20) {
|
export function useWorkspaces(page = 1, pageSize = 20) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["workspaces", page, pageSize],
|
queryKey: QK.workspaces(page, pageSize),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data, error } = await client.current.POST("/v3/workspaces/list", {
|
const { data, error } = await client.current.POST("/v3/workspaces/list", {
|
||||||
params: { query: { page, page_size: pageSize } },
|
params: { query: { page, page_size: pageSize } },
|
||||||
body: {},
|
body: {},
|
||||||
});
|
});
|
||||||
if (error) throw new Error(JSON.stringify(error));
|
if (error) err(error);
|
||||||
return data;
|
return data!;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useWorkspace(workspaceId: string) {
|
export function useWorkspace(workspaceId: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["workspace", workspaceId],
|
queryKey: QK.workspace(workspaceId),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data, error } = await client.current.POST("/v3/workspaces", {
|
const { data, error } = await client.current.POST("/v3/workspaces", {
|
||||||
body: { id: workspaceId, metadata: {} },
|
body: { id: workspaceId, metadata: {} },
|
||||||
});
|
});
|
||||||
if (error) throw new Error(JSON.stringify(error));
|
if (error) err(error);
|
||||||
return data;
|
return data!;
|
||||||
},
|
},
|
||||||
enabled: Boolean(workspaceId),
|
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) {
|
export function useQueueStatus(workspaceId: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["queue-status", workspaceId],
|
queryKey: QK.queueStatus(workspaceId),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data, error } = await client.current.GET(
|
const { data, error } = await client.current.GET(
|
||||||
"/v3/workspaces/{workspace_id}/queue/status",
|
"/v3/workspaces/{workspace_id}/queue/status",
|
||||||
{ params: { path: { workspace_id: workspaceId } } },
|
{ params: { path: { workspace_id: workspaceId } } },
|
||||||
);
|
);
|
||||||
if (error) throw new Error(JSON.stringify(error));
|
if (error) err(error);
|
||||||
return data;
|
return data!;
|
||||||
},
|
},
|
||||||
enabled: Boolean(workspaceId),
|
enabled: Boolean(workspaceId),
|
||||||
refetchInterval: 10_000,
|
refetchInterval: 10_000,
|
||||||
@@ -49,7 +106,7 @@ export function useQueueStatus(workspaceId: string) {
|
|||||||
|
|
||||||
export function useSearchWorkspace(workspaceId: string, query: string, enabled = false) {
|
export function useSearchWorkspace(workspaceId: string, query: string, enabled = false) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["workspace-search", workspaceId, query],
|
queryKey: QK.workspaceSearch(workspaceId, query),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data, error } = await client.current.POST(
|
const { data, error } = await client.current.POST(
|
||||||
"/v3/workspaces/{workspace_id}/search",
|
"/v3/workspaces/{workspace_id}/search",
|
||||||
@@ -58,8 +115,8 @@ export function useSearchWorkspace(workspaceId: string, query: string, enabled =
|
|||||||
body: { query, limit: 20 },
|
body: { query, limit: 20 },
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (error) throw new Error(JSON.stringify(error));
|
if (error) err(error);
|
||||||
return data;
|
return data!;
|
||||||
},
|
},
|
||||||
enabled: enabled && Boolean(workspaceId) && Boolean(query),
|
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) {
|
export function usePeers(workspaceId: string, page = 1, pageSize = 20) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["peers", workspaceId, page, pageSize],
|
queryKey: QK.peers(workspaceId, page, pageSize),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data, error } = await client.current.POST(
|
const { data, error } = await client.current.POST(
|
||||||
"/v3/workspaces/{workspace_id}/peers/list",
|
"/v3/workspaces/{workspace_id}/peers/list",
|
||||||
@@ -78,8 +135,8 @@ export function usePeers(workspaceId: string, page = 1, pageSize = 20) {
|
|||||||
body: {},
|
body: {},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (error) throw new Error(JSON.stringify(error));
|
if (error) err(error);
|
||||||
return data;
|
return data!;
|
||||||
},
|
},
|
||||||
enabled: Boolean(workspaceId),
|
enabled: Boolean(workspaceId),
|
||||||
});
|
});
|
||||||
@@ -87,7 +144,7 @@ export function usePeers(workspaceId: string, page = 1, pageSize = 20) {
|
|||||||
|
|
||||||
export function usePeer(workspaceId: string, peerId: string) {
|
export function usePeer(workspaceId: string, peerId: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["peer", workspaceId, peerId],
|
queryKey: QK.peer(workspaceId, peerId),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data, error } = await client.current.POST(
|
const { data, error } = await client.current.POST(
|
||||||
"/v3/workspaces/{workspace_id}/peers",
|
"/v3/workspaces/{workspace_id}/peers",
|
||||||
@@ -96,16 +153,34 @@ export function usePeer(workspaceId: string, peerId: string) {
|
|||||||
body: { id: peerId, metadata: {} },
|
body: { id: peerId, metadata: {} },
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (error) throw new Error(JSON.stringify(error));
|
if (error) err(error);
|
||||||
return data;
|
return data!;
|
||||||
},
|
},
|
||||||
enabled: Boolean(workspaceId) && Boolean(peerId),
|
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) {
|
export function usePeerRepresentation(workspaceId: string, peerId: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["peer-representation", workspaceId, peerId],
|
queryKey: QK.peerRepresentation(workspaceId, peerId),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data, error } = await client.current.POST(
|
const { data, error } = await client.current.POST(
|
||||||
"/v3/workspaces/{workspace_id}/peers/{peer_id}/representation",
|
"/v3/workspaces/{workspace_id}/peers/{peer_id}/representation",
|
||||||
@@ -114,8 +189,8 @@ export function usePeerRepresentation(workspaceId: string, peerId: string) {
|
|||||||
body: { max_conclusions: 20 },
|
body: { max_conclusions: 20 },
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (error) throw new Error(JSON.stringify(error));
|
if (error) err(error);
|
||||||
return data;
|
return data!;
|
||||||
},
|
},
|
||||||
enabled: Boolean(workspaceId) && Boolean(peerId),
|
enabled: Boolean(workspaceId) && Boolean(peerId),
|
||||||
});
|
});
|
||||||
@@ -123,33 +198,49 @@ export function usePeerRepresentation(workspaceId: string, peerId: string) {
|
|||||||
|
|
||||||
export function usePeerCard(workspaceId: string, peerId: string) {
|
export function usePeerCard(workspaceId: string, peerId: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["peer-card", workspaceId, peerId],
|
queryKey: QK.peerCard(workspaceId, peerId),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data, error } = await client.current.GET(
|
const { data, error } = await client.current.GET(
|
||||||
"/v3/workspaces/{workspace_id}/peers/{peer_id}/card",
|
"/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));
|
if (error) err(error);
|
||||||
return data;
|
return data!;
|
||||||
},
|
},
|
||||||
enabled: Boolean(workspaceId) && Boolean(peerId),
|
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) {
|
export function usePeerContext(workspaceId: string, peerId: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["peer-context", workspaceId, peerId],
|
queryKey: QK.peerContext(workspaceId, peerId),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data, error } = await client.current.GET(
|
const { data, error } = await client.current.GET(
|
||||||
"/v3/workspaces/{workspace_id}/peers/{peer_id}/context",
|
"/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));
|
if (error) err(error);
|
||||||
return data;
|
return data!;
|
||||||
},
|
},
|
||||||
enabled: Boolean(workspaceId) && Boolean(peerId),
|
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) {
|
export function usePeerSessions(workspaceId: string, peerId: string, page = 1, pageSize = 20) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["peer-sessions", workspaceId, peerId, page, pageSize],
|
queryKey: QK.peerSessions(workspaceId, peerId, page, pageSize),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data, error } = await client.current.POST(
|
const { data, error } = await client.current.POST(
|
||||||
"/v3/workspaces/{workspace_id}/peers/{peer_id}/sessions",
|
"/v3/workspaces/{workspace_id}/peers/{peer_id}/sessions",
|
||||||
@@ -169,15 +260,31 @@ export function usePeerSessions(workspaceId: string, peerId: string, page = 1, p
|
|||||||
body: {},
|
body: {},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (error) throw new Error(JSON.stringify(error));
|
if (error) err(error);
|
||||||
return data;
|
return data!;
|
||||||
},
|
},
|
||||||
enabled: Boolean(workspaceId) && Boolean(peerId),
|
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) {
|
export function useChat(workspaceId: string, peerId: string) {
|
||||||
const queryClient = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (message: string) => {
|
mutationFn: async (message: string) => {
|
||||||
const { data, error } = await client.current.POST(
|
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" },
|
body: { query: message, stream: false, reasoning_level: "low" },
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (error) throw new Error(JSON.stringify(error));
|
if (error) err(error);
|
||||||
return data;
|
return data!;
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
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) {
|
export function useSessions(workspaceId: string, page = 1, pageSize = 20) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["sessions", workspaceId, page, pageSize],
|
queryKey: QK.sessions(workspaceId, page, pageSize),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data, error } = await client.current.POST(
|
const { data, error } = await client.current.POST(
|
||||||
"/v3/workspaces/{workspace_id}/sessions/list",
|
"/v3/workspaces/{workspace_id}/sessions/list",
|
||||||
@@ -212,13 +319,80 @@ export function useSessions(workspaceId: string, page = 1, pageSize = 20) {
|
|||||||
body: {},
|
body: {},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (error) throw new Error(JSON.stringify(error));
|
if (error) err(error);
|
||||||
return data;
|
return data!;
|
||||||
},
|
},
|
||||||
enabled: Boolean(workspaceId),
|
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(
|
export function useSessionMessages(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
@@ -226,7 +400,7 @@ export function useSessionMessages(
|
|||||||
pageSize = 50,
|
pageSize = 50,
|
||||||
) {
|
) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["session-messages", workspaceId, sessionId, page, pageSize],
|
queryKey: QK.sessionMessages(workspaceId, sessionId, page, pageSize),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data, error } = await client.current.POST(
|
const { data, error } = await client.current.POST(
|
||||||
"/v3/workspaces/{workspace_id}/sessions/{session_id}/messages/list",
|
"/v3/workspaces/{workspace_id}/sessions/{session_id}/messages/list",
|
||||||
@@ -238,25 +412,204 @@ export function useSessionMessages(
|
|||||||
body: {},
|
body: {},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (error) throw new Error(JSON.stringify(error));
|
if (error) err(error);
|
||||||
return data;
|
return data!;
|
||||||
},
|
},
|
||||||
enabled: Boolean(workspaceId) && Boolean(sessionId),
|
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) {
|
export function useSessionSummaries(workspaceId: string, sessionId: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["session-summaries", workspaceId, sessionId],
|
queryKey: QK.sessionSummaries(workspaceId, sessionId),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data, error } = await client.current.GET(
|
const { data, error } = await client.current.GET(
|
||||||
"/v3/workspaces/{workspace_id}/sessions/{session_id}/summaries",
|
"/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));
|
if (error) err(error);
|
||||||
return data;
|
return data!;
|
||||||
},
|
},
|
||||||
enabled: Boolean(workspaceId) && Boolean(sessionId),
|
enabled: Boolean(workspaceId) && Boolean(sessionId),
|
||||||
});
|
});
|
||||||
@@ -264,16 +617,14 @@ export function useSessionSummaries(workspaceId: string, sessionId: string) {
|
|||||||
|
|
||||||
export function useSessionContext(workspaceId: string, sessionId: string) {
|
export function useSessionContext(workspaceId: string, sessionId: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["session-context", workspaceId, sessionId],
|
queryKey: QK.sessionContext(workspaceId, sessionId),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data, error } = await client.current.GET(
|
const { data, error } = await client.current.GET(
|
||||||
"/v3/workspaces/{workspace_id}/sessions/{session_id}/context",
|
"/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));
|
if (error) err(error);
|
||||||
return data;
|
return data!;
|
||||||
},
|
},
|
||||||
enabled: Boolean(workspaceId) && Boolean(sessionId),
|
enabled: Boolean(workspaceId) && Boolean(sessionId),
|
||||||
});
|
});
|
||||||
@@ -288,7 +639,7 @@ export function useConclusions(
|
|||||||
pageSize = 20,
|
pageSize = 20,
|
||||||
) {
|
) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["conclusions", workspaceId, filters, page, pageSize],
|
queryKey: QK.conclusions(workspaceId, filters, page, pageSize),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data, error } = await client.current.POST(
|
const { data, error } = await client.current.POST(
|
||||||
"/v3/workspaces/{workspace_id}/conclusions/list",
|
"/v3/workspaces/{workspace_id}/conclusions/list",
|
||||||
@@ -300,8 +651,8 @@ export function useConclusions(
|
|||||||
body: filters,
|
body: filters,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (error) throw new Error(JSON.stringify(error));
|
if (error) err(error);
|
||||||
return data;
|
return data!;
|
||||||
},
|
},
|
||||||
enabled: Boolean(workspaceId),
|
enabled: Boolean(workspaceId),
|
||||||
});
|
});
|
||||||
@@ -314,7 +665,7 @@ export function useQueryConclusions(
|
|||||||
enabled = false,
|
enabled = false,
|
||||||
) {
|
) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["conclusions-query", workspaceId, query, filters],
|
queryKey: QK.conclusionsQuery(workspaceId, query, filters),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data, error } = await client.current.POST(
|
const { data, error } = await client.current.POST(
|
||||||
"/v3/workspaces/{workspace_id}/conclusions/query",
|
"/v3/workspaces/{workspace_id}/conclusions/query",
|
||||||
@@ -323,9 +674,137 @@ export function useQueryConclusions(
|
|||||||
body: { query, top_k: 10, ...filters },
|
body: { query, top_k: 10, ...filters },
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (error) throw new Error(JSON.stringify(error));
|
if (error) err(error);
|
||||||
return data;
|
return data!;
|
||||||
},
|
},
|
||||||
enabled: enabled && Boolean(workspaceId) && Boolean(query),
|
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 { useState } from "react";
|
||||||
import { Link, useParams } from "@tanstack/react-router";
|
import { Link, useParams } from "@tanstack/react-router";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import { Lightbulb, Search, X, Clock, ArrowLeft, Eye } from "lucide-react";
|
import { z } from "zod";
|
||||||
import { useConclusions, useQueryConclusions } from "@/api/queries";
|
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 { PageLoader } from "@/components/shared/LoadingSpinner";
|
||||||
import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
||||||
import { Pagination } from "@/components/shared/Pagination";
|
import { Pagination } from "@/components/shared/Pagination";
|
||||||
import { EmptyState } from "@/components/shared/EmptyState";
|
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";
|
import type { components } from "@/api/schema.d.ts";
|
||||||
|
|
||||||
type Conclusion = components["schemas"]["Conclusion"];
|
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 = {
|
const itemVariants = {
|
||||||
hidden: { opacity: 0, y: 8 },
|
hidden: { opacity: 0, y: 8 },
|
||||||
show: (i: number) => ({
|
show: (i: number) => ({
|
||||||
@@ -25,6 +40,8 @@ export function ConclusionBrowser() {
|
|||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [activeSearch, setActiveSearch] = 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, isLoading, error } = useConclusions(workspaceId, {}, page);
|
||||||
const { data: searchResults, isLoading: searchLoading } = useQueryConclusions(
|
const { data: searchResults, isLoading: searchLoading } = useQueryConclusions(
|
||||||
@@ -33,6 +50,8 @@ export function ConclusionBrowser() {
|
|||||||
{},
|
{},
|
||||||
Boolean(activeSearch),
|
Boolean(activeSearch),
|
||||||
);
|
);
|
||||||
|
const createConclusion = useCreateConclusion(workspaceId);
|
||||||
|
const deleteConclusion = useDeleteConclusion(workspaceId);
|
||||||
|
|
||||||
const conclusions: Conclusion[] = (data as { items?: Conclusion[] } | undefined)?.items ?? [];
|
const conclusions: Conclusion[] = (data as { items?: Conclusion[] } | undefined)?.items ?? [];
|
||||||
const totalPages = (data as { pages?: number } | undefined)?.pages ?? 1;
|
const totalPages = (data as { pages?: number } | undefined)?.pages ?? 1;
|
||||||
@@ -50,39 +69,47 @@ export function ConclusionBrowser() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8 max-w-3xl mx-auto">
|
<div className="p-8 max-w-3xl mx-auto">
|
||||||
<motion.div
|
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }} className="mb-8">
|
||||||
initial={{ opacity: 0, y: -8 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
className="mb-8"
|
|
||||||
>
|
|
||||||
<Link
|
<Link
|
||||||
to="/workspaces/$workspaceId"
|
to="/workspaces/$workspaceId"
|
||||||
params={{ workspaceId }}
|
params={{ workspaceId } as never}
|
||||||
className="inline-flex items-center gap-1.5 text-xs mb-4 transition-colors"
|
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} />
|
<ArrowLeft className="w-3 h-3" strokeWidth={1.5} />
|
||||||
{workspaceId}
|
{workspaceId}
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<Lightbulb className="w-5 h-5" style={{ color: "#6366f1" }} strokeWidth={1.5} />
|
<Lightbulb className="w-5 h-5" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
|
||||||
<h1 className="text-xl font-semibold tracking-tight" style={{ color: "#e4e4f0" }}>
|
<h1 className="text-xl font-semibold tracking-tight" style={{ color: "var(--text-1)" }}>
|
||||||
Conclusions
|
Conclusions
|
||||||
</h1>
|
</h1>
|
||||||
{total > 0 && !activeSearch && (
|
{total > 0 && !activeSearch && (
|
||||||
<span
|
<span
|
||||||
className="ml-auto text-xs font-mono px-2 py-0.5 rounded-full"
|
className="ml-auto text-xs font-mono px-2 py-0.5 rounded-full"
|
||||||
style={{
|
style={{
|
||||||
background: "rgba(99,102,241,0.1)",
|
background: "var(--accent-dim)",
|
||||||
color: "#818cf8",
|
color: "var(--accent-text)",
|
||||||
border: "1px solid rgba(99,102,241,0.2)",
|
border: "1px solid var(--accent-border)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{total}
|
{total}
|
||||||
</span>
|
</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>
|
</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
|
Distilled memory observations about peers
|
||||||
</p>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -92,7 +119,7 @@ export function ConclusionBrowser() {
|
|||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<Search
|
<Search
|
||||||
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4"
|
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}
|
strokeWidth={1.5}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
@@ -100,24 +127,13 @@ export function ConclusionBrowser() {
|
|||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
placeholder="Semantic search across conclusions..."
|
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"
|
className="theme-input w-full rounded-xl pl-9 pr-4 py-2.5 text-sm font-mono"
|
||||||
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)";
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="px-4 py-2.5 rounded-xl text-sm font-medium transition-all"
|
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
|
Search
|
||||||
</button>
|
</button>
|
||||||
@@ -131,9 +147,9 @@ export function ConclusionBrowser() {
|
|||||||
onClick={() => { setActiveSearch(""); setSearchQuery(""); }}
|
onClick={() => { setActiveSearch(""); setSearchQuery(""); }}
|
||||||
className="px-3 py-2.5 rounded-xl text-sm transition-all"
|
className="px-3 py-2.5 rounded-xl text-sm transition-all"
|
||||||
style={{
|
style={{
|
||||||
background: "rgba(255,255,255,0.05)",
|
background: "var(--surface)",
|
||||||
border: "1px solid rgba(255,255,255,0.08)",
|
border: "1px solid var(--border)",
|
||||||
color: "rgba(148,163,184,0.7)",
|
color: "var(--text-3)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4" strokeWidth={1.5} />
|
<X className="w-4 h-4" strokeWidth={1.5} />
|
||||||
@@ -164,7 +180,7 @@ export function ConclusionBrowser() {
|
|||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
className="text-xs font-mono mb-3"
|
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" : ""}{" "}
|
{displayedConclusions.length} result{displayedConclusions.length !== 1 ? "s" : ""}{" "}
|
||||||
for “{activeSearch}”
|
for “{activeSearch}”
|
||||||
@@ -178,40 +194,46 @@ export function ConclusionBrowser() {
|
|||||||
variants={itemVariants}
|
variants={itemVariants}
|
||||||
initial="hidden"
|
initial="hidden"
|
||||||
animate="show"
|
animate="show"
|
||||||
className="rounded-xl p-5"
|
className="group rounded-xl p-5"
|
||||||
style={{
|
style={{
|
||||||
background: "rgba(255,255,255,0.02)",
|
background: "var(--surface)",
|
||||||
border: "1px solid rgba(255,255,255,0.06)",
|
border: "1px solid var(--border)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<p
|
<div className="flex items-start justify-between gap-3">
|
||||||
className="text-sm leading-relaxed whitespace-pre-wrap"
|
<p className="text-sm leading-relaxed whitespace-pre-wrap flex-1" style={{ color: "var(--text-2)" }}>
|
||||||
style={{ color: "#d4d4f5" }}
|
{c.content}
|
||||||
>
|
</p>
|
||||||
{c.content}
|
<button
|
||||||
</p>
|
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
|
<div
|
||||||
className="flex items-center gap-3 mt-4 pt-3"
|
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">
|
<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} />
|
<Eye className="w-3 h-3" style={{ color: "var(--text-4)" }} strokeWidth={1.5} />
|
||||||
<span className="text-xs font-mono" style={{ color: "rgba(148,163,184,0.4)" }}>
|
<span className="text-xs font-mono" style={{ color: "var(--text-4)" }}>
|
||||||
{c.observer_id}
|
{c.observer_id}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{c.observed_id && (
|
{c.observed_id && (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<span className="text-xs" style={{ color: "rgba(148,163,184,0.2)" }}>→</span>
|
<span className="text-xs" style={{ color: "var(--text-4)" }}>→</span>
|
||||||
<span className="text-xs font-mono" style={{ color: "rgba(148,163,184,0.4)" }}>
|
<span className="text-xs font-mono" style={{ color: "var(--text-4)" }}>
|
||||||
{c.observed_id}
|
{c.observed_id}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{c.created_at && (
|
{c.created_at && (
|
||||||
<div className="flex items-center gap-1 ml-auto">
|
<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} />
|
<Clock className="w-3 h-3" style={{ color: "var(--text-4)" }} strokeWidth={1.5} />
|
||||||
<span className="text-xs font-mono" style={{ color: "rgba(148,163,184,0.3)" }}>
|
<span className="text-xs font-mono" style={{ color: "var(--text-4)" }}>
|
||||||
{new Date(c.created_at).toLocaleString()}
|
{new Date(c.created_at).toLocaleString()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
</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 { useState } from "react";
|
||||||
import { Link, useNavigate, useParams } from "@tanstack/react-router";
|
import { Link, useNavigate, useParams } from "@tanstack/react-router";
|
||||||
import { motion } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import { User, MessageCircle } from "lucide-react";
|
import { User, MessageCircle, Search, Save, X } from "lucide-react";
|
||||||
import { usePeer, usePeerCard, usePeerContext, usePeerRepresentation } from "@/api/queries";
|
import {
|
||||||
|
usePeer,
|
||||||
|
usePeerCard,
|
||||||
|
usePeerContext,
|
||||||
|
usePeerRepresentation,
|
||||||
|
useSetPeerCard,
|
||||||
|
useSearchPeer,
|
||||||
|
} from "@/api/queries";
|
||||||
import { PageLoader } from "@/components/shared/LoadingSpinner";
|
import { PageLoader } from "@/components/shared/LoadingSpinner";
|
||||||
import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
||||||
import { JsonViewer } from "@/components/shared/JsonViewer";
|
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() {
|
export function PeerDetail() {
|
||||||
const { workspaceId, peerId } = useParams({ strict: false }) as {
|
const { workspaceId, peerId } = useParams({ strict: false }) as {
|
||||||
@@ -22,10 +30,24 @@ export function PeerDetail() {
|
|||||||
const { data: context, isLoading: contextLoading } = usePeerContext(workspaceId, peerId);
|
const { data: context, isLoading: contextLoading } = usePeerContext(workspaceId, peerId);
|
||||||
const { data: representation, isLoading: repLoading } = usePeerRepresentation(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 }> = [
|
const tabs: Array<{ id: Tab; label: string }> = [
|
||||||
{ id: "context", label: "Context" },
|
{ id: "context", label: "Context" },
|
||||||
{ id: "card", label: "Card" },
|
{ id: "card", label: "Card" },
|
||||||
{ id: "representation", label: "Representation" },
|
{ id: "representation", label: "Representation" },
|
||||||
|
{ id: "search", label: "Search" },
|
||||||
{ id: "metadata", label: "Metadata" },
|
{ 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"
|
className="shrink-0 flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium transition-all"
|
||||||
style={{
|
style={{ background: "var(--accent)", color: "#fff" }}
|
||||||
background: "var(--accent)",
|
|
||||||
color: "#fff",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<MessageCircle className="w-4 h-4" strokeWidth={1.5} />
|
<MessageCircle className="w-4 h-4" strokeWidth={1.5} />
|
||||||
Chat
|
Chat
|
||||||
@@ -82,7 +101,6 @@ export function PeerDetail() {
|
|||||||
|
|
||||||
{!isLoading && peer && (
|
{!isLoading && peer && (
|
||||||
<>
|
<>
|
||||||
{/* Tab bar */}
|
|
||||||
<div
|
<div
|
||||||
className="flex gap-0.5 mb-4 p-1 rounded-xl"
|
className="flex gap-0.5 mb-4 p-1 rounded-xl"
|
||||||
style={{ background: "var(--bg-3)", border: "1px solid var(--border)" }}
|
style={{ background: "var(--bg-3)", border: "1px solid var(--border)" }}
|
||||||
@@ -107,7 +125,6 @@ export function PeerDetail() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tab content */}
|
|
||||||
<motion.div
|
<motion.div
|
||||||
key={tab}
|
key={tab}
|
||||||
initial={{ opacity: 0, y: 4 }}
|
initial={{ opacity: 0, y: 4 }}
|
||||||
@@ -127,18 +144,72 @@ export function PeerDetail() {
|
|||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === "card" && (
|
{tab === "card" && (
|
||||||
cardLoading ? <PageLoader /> : (
|
cardLoading ? <PageLoader /> : (
|
||||||
<>
|
<>
|
||||||
<h2 className="text-sm font-medium mb-3" style={{ color: "var(--text-1)" }}>Peer Card</h2>
|
<div className="flex items-center justify-between mb-3">
|
||||||
{typeof card === "string" ? (
|
<h2 className="text-sm font-medium" style={{ color: "var(--text-1)" }}>Peer Card</h2>
|
||||||
<p className="text-sm whitespace-pre-wrap" style={{ color: "var(--text-2)" }}>{card}</p>
|
{cardDraft === null ? (
|
||||||
) : (
|
<button
|
||||||
<JsonViewer data={card} maxHeight="400px" />
|
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" && (
|
{tab === "representation" && (
|
||||||
repLoading ? <PageLoader /> : (
|
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" && (
|
{tab === "metadata" && (
|
||||||
<>
|
<>
|
||||||
<h2 className="text-sm font-medium mb-3" style={{ color: "var(--text-1)" }}>Peer Metadata</h2>
|
<h2 className="text-sm font-medium mb-3" style={{ color: "var(--text-1)" }}>Peer Metadata</h2>
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export function PeerList() {
|
|||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
to="/workspaces/$workspaceId"
|
to="/workspaces/$workspaceId"
|
||||||
params={{ workspaceId }}
|
params={{ workspaceId } as never}
|
||||||
className="inline-flex items-center gap-1.5 text-xs mb-4 transition-colors"
|
className="inline-flex items-center gap-1.5 text-xs mb-4 transition-colors"
|
||||||
style={{ color: "rgba(148,163,184,0.5)" }}
|
style={{ color: "rgba(148,163,184,0.5)" }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,38 +1,89 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Link, useParams } from "@tanstack/react-router";
|
import { Link, useParams, useNavigate } from "@tanstack/react-router";
|
||||||
import { motion } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import { MessageSquare } from "lucide-react";
|
import { MessageSquare, Trash2, Copy, Search, Users, X } from "lucide-react";
|
||||||
import { useSessionMessages, useSessionSummaries, useSessionContext } from "@/api/queries";
|
import {
|
||||||
|
useSessionMessages,
|
||||||
|
useSessionSummaries,
|
||||||
|
useSessionContext,
|
||||||
|
useSessionPeers,
|
||||||
|
useDeleteSession,
|
||||||
|
useCloneSession,
|
||||||
|
useSearchSession,
|
||||||
|
useRemovePeersFromSession,
|
||||||
|
usePeers,
|
||||||
|
useAddPeersToSession,
|
||||||
|
} from "@/api/queries";
|
||||||
import { PageLoader } from "@/components/shared/LoadingSpinner";
|
import { PageLoader } from "@/components/shared/LoadingSpinner";
|
||||||
import { Pagination } from "@/components/shared/Pagination";
|
import { Pagination } from "@/components/shared/Pagination";
|
||||||
import { Badge } from "@/components/shared/Badge";
|
import { Badge } from "@/components/shared/Badge";
|
||||||
import { JsonViewer } from "@/components/shared/JsonViewer";
|
import { JsonViewer } from "@/components/shared/JsonViewer";
|
||||||
|
import { ConfirmDialog } from "@/components/shared/ConfirmDialog";
|
||||||
import type { components } from "@/api/schema.d.ts";
|
import type { components } from "@/api/schema.d.ts";
|
||||||
|
|
||||||
type Message = components["schemas"]["Message"];
|
type Message = components["schemas"]["Message"];
|
||||||
type Tab = "messages" | "summaries" | "context";
|
type Tab = "messages" | "summaries" | "context" | "peers";
|
||||||
|
|
||||||
export function SessionDetail() {
|
export function SessionDetail() {
|
||||||
const { workspaceId, sessionId } = useParams({ strict: false }) as {
|
const { workspaceId, sessionId } = useParams({ strict: false }) as {
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
};
|
};
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [tab, setTab] = useState<Tab>("messages");
|
const [tab, setTab] = useState<Tab>("messages");
|
||||||
const [page, setPage] = useState(1);
|
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: msgData, isLoading: msgsLoading } = useSessionMessages(workspaceId, sessionId, page);
|
||||||
const { data: summaries, isLoading: summariesLoading } = useSessionSummaries(workspaceId, sessionId);
|
const { data: summaries, isLoading: summariesLoading } = useSessionSummaries(workspaceId, sessionId);
|
||||||
const { data: context, isLoading: contextLoading } = useSessionContext(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 messages: Message[] = (msgData as { items?: Message[] } | undefined)?.items ?? [];
|
||||||
const totalPages = (msgData as { pages?: number } | undefined)?.pages ?? 1;
|
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 }> = [
|
const tabs: Array<{ id: Tab; label: string }> = [
|
||||||
{ id: "messages", label: "Messages" },
|
{ id: "messages", label: "Messages" },
|
||||||
{ id: "summaries", label: "Summaries" },
|
{ id: "summaries", label: "Summaries" },
|
||||||
{ id: "context", label: "Context" },
|
{ 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 (
|
return (
|
||||||
<div className="page-container">
|
<div className="page-container">
|
||||||
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }}>
|
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }}>
|
||||||
@@ -46,18 +97,103 @@ export function SessionDetail() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-start justify-between gap-4 mb-1">
|
||||||
<MessageSquare className="w-5 h-5" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<h1
|
<MessageSquare className="w-5 h-5 flex-shrink-0" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
|
||||||
className="text-xl font-semibold font-mono break-all tracking-tight"
|
<h1 className="text-xl font-semibold font-mono break-all tracking-tight" style={{ color: "var(--text-1)" }}>
|
||||||
style={{ color: "var(--text-1)" }}
|
{sessionId}
|
||||||
>
|
</h1>
|
||||||
{sessionId}
|
</div>
|
||||||
</h1>
|
<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>
|
</div>
|
||||||
<p className="text-sm" style={{ color: "var(--text-2)" }}>Session detail</p>
|
<p className="text-sm" style={{ color: "var(--text-2)" }}>Session detail</p>
|
||||||
</motion.div>
|
</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">
|
<div className="mt-8">
|
||||||
{/* Tab bar */}
|
{/* Tab bar */}
|
||||||
<div
|
<div
|
||||||
@@ -99,11 +235,7 @@ export function SessionDetail() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{messages.map((msg) => (
|
{messages.map((msg) => (
|
||||||
<div
|
<div key={msg.id} className="pb-4" style={{ borderBottom: "1px solid var(--border)" }}>
|
||||||
key={msg.id}
|
|
||||||
className="pb-4"
|
|
||||||
style={{ borderBottom: "1px solid var(--border)" }}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||||
<Badge variant={msg.peer_id ? "blue" : "default"}>
|
<Badge variant={msg.peer_id ? "blue" : "default"}>
|
||||||
{msg.peer_id ?? "system"}
|
{msg.peer_id ?? "system"}
|
||||||
@@ -119,10 +251,7 @@ export function SessionDetail() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p
|
<p className="text-sm whitespace-pre-wrap leading-relaxed" style={{ color: "var(--text-2)" }}>
|
||||||
className="text-sm whitespace-pre-wrap leading-relaxed"
|
|
||||||
style={{ color: "var(--text-2)" }}
|
|
||||||
>
|
|
||||||
{msg.content}
|
{msg.content}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
</motion.div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export function SessionList() {
|
|||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
to="/workspaces/$workspaceId"
|
to="/workspaces/$workspaceId"
|
||||||
params={{ workspaceId }}
|
params={{ workspaceId } as never}
|
||||||
className="inline-flex items-center gap-1.5 text-xs mb-4 transition-colors"
|
className="inline-flex items-center gap-1.5 text-xs mb-4 transition-colors"
|
||||||
style={{ color: "rgba(148,163,184,0.5)" }}
|
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 { motion } from "framer-motion";
|
||||||
import { Boxes, Users, MessageSquare, Lightbulb, ArrowLeft, CircleDot } from "lucide-react";
|
import { Boxes, Users, MessageSquare, Lightbulb, ArrowLeft, CircleDot, Trash2, Zap, Webhook } from "lucide-react";
|
||||||
import { useWorkspace, useQueueStatus } from "@/api/queries";
|
import {
|
||||||
|
useWorkspace,
|
||||||
|
useQueueStatus,
|
||||||
|
useDeleteWorkspace,
|
||||||
|
useScheduleDream,
|
||||||
|
} from "@/api/queries";
|
||||||
import { PageLoader } from "@/components/shared/LoadingSpinner";
|
import { PageLoader } from "@/components/shared/LoadingSpinner";
|
||||||
import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
||||||
import { JsonViewer } from "@/components/shared/JsonViewer";
|
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: "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: "Sessions", icon: MessageSquare, to: "sessions" as const, description: "View conversation sessions" },
|
||||||
{ label: "Conclusions", icon: Lightbulb, to: "conclusions" as const, description: "Browse memory conclusions" },
|
{ 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() {
|
export function WorkspaceDetail() {
|
||||||
const { workspaceId } = useParams({ strict: false }) as { workspaceId: string };
|
const { workspaceId } = useParams({ strict: false }) as { workspaceId: string };
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const { data: workspace, isLoading, error } = useWorkspace(workspaceId);
|
const { data: workspace, isLoading, error } = useWorkspace(workspaceId);
|
||||||
const { data: queue } = useQueueStatus(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 (
|
return (
|
||||||
<div className="page-container">
|
<div className="page-container">
|
||||||
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }}>
|
<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} />
|
<ArrowLeft className="w-3 h-3" strokeWidth={1.5} />
|
||||||
Workspaces
|
Workspaces
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-start justify-between gap-4 mb-1">
|
||||||
<Boxes className="w-5 h-5" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<h1
|
<Boxes className="w-5 h-5 flex-shrink-0" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
|
||||||
className="text-xl font-semibold font-mono break-all tracking-tight"
|
<h1
|
||||||
style={{ color: "var(--text-1)" }}
|
className="text-xl font-semibold font-mono break-all tracking-tight"
|
||||||
>
|
style={{ color: "var(--text-1)" }}
|
||||||
{workspaceId}
|
>
|
||||||
</h1>
|
{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>
|
</div>
|
||||||
<p className="text-sm" style={{ color: "var(--text-2)" }}>Workspace overview</p>
|
<p className="text-sm" style={{ color: "var(--text-2)" }}>Workspace overview</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -47,30 +97,23 @@ export function WorkspaceDetail() {
|
|||||||
{!isLoading && workspace && (
|
{!isLoading && workspace && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Nav cards */}
|
{/* Nav cards */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||||
{sections.map((s, i) => {
|
{NAV_SECTIONS.map((s, i) => {
|
||||||
const Icon = s.icon;
|
const Icon = s.icon;
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={s.to}
|
key={s.to}
|
||||||
initial={{ opacity: 0, y: 12 }}
|
initial={{ opacity: 0, y: 12 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
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
|
<Link
|
||||||
to={`/workspaces/$workspaceId/${s.to}` as never}
|
to={`/workspaces/$workspaceId/${s.to}` as never}
|
||||||
params={{ workspaceId } as never}
|
params={{ workspaceId } as never}
|
||||||
className="block rounded-xl p-5 group transition-all theme-card"
|
className="block rounded-xl p-5 group transition-all theme-card"
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon className="w-5 h-5 mb-3" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
|
||||||
className="w-5 h-5 mb-3"
|
<h2 className="text-sm font-medium mb-0.5" style={{ color: "var(--text-1)" }}>
|
||||||
style={{ color: "var(--accent)" }}
|
|
||||||
strokeWidth={1.5}
|
|
||||||
/>
|
|
||||||
<h2
|
|
||||||
className="text-sm font-medium mb-0.5"
|
|
||||||
style={{ color: "var(--text-1)" }}
|
|
||||||
>
|
|
||||||
{s.label}
|
{s.label}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xs" style={{ color: "var(--text-3)" }}>
|
<p className="text-xs" style={{ color: "var(--text-3)" }}>
|
||||||
@@ -87,7 +130,7 @@ export function WorkspaceDetail() {
|
|||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 8 }}
|
initial={{ opacity: 0, y: 8 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ delay: 0.25 }}
|
transition={{ delay: 0.28 }}
|
||||||
className="rounded-xl p-5 theme-card"
|
className="rounded-xl p-5 theme-card"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
@@ -96,10 +139,7 @@ export function WorkspaceDetail() {
|
|||||||
</h2>
|
</h2>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
{queue.pending_work_units > 0 ? (
|
{queue.pending_work_units > 0 ? (
|
||||||
<motion.div
|
<motion.div animate={{ opacity: [0.5, 1, 0.5] }} transition={{ duration: 1.5, repeat: Infinity }}>
|
||||||
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} />
|
<CircleDot className="w-3.5 h-3.5" style={{ color: "#f59e0b" }} strokeWidth={2} />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
) : (
|
) : (
|
||||||
@@ -116,10 +156,7 @@ export function WorkspaceDetail() {
|
|||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
<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) => (
|
{(["total_work_units", "completed_work_units", "in_progress_work_units", "pending_work_units"] as const).map((key) => (
|
||||||
<div key={key}>
|
<div key={key}>
|
||||||
<div
|
<div className="text-2xl font-semibold font-mono" style={{ color: "var(--text-1)" }}>
|
||||||
className="text-2xl font-semibold font-mono"
|
|
||||||
style={{ color: "var(--text-1)" }}
|
|
||||||
>
|
|
||||||
{queue[key]}
|
{queue[key]}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs capitalize mt-0.5" style={{ color: "var(--text-3)" }}>
|
<div className="text-xs capitalize mt-0.5" style={{ color: "var(--text-3)" }}>
|
||||||
@@ -135,7 +172,7 @@ export function WorkspaceDetail() {
|
|||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
transition={{ delay: 0.35 }}
|
transition={{ delay: 0.38 }}
|
||||||
className="rounded-xl p-5 theme-card"
|
className="rounded-xl p-5 theme-card"
|
||||||
>
|
>
|
||||||
<h2 className="text-sm font-medium mb-3" style={{ color: "var(--text-1)" }}>
|
<h2 className="text-sm font-medium mb-3" style={{ color: "var(--text-1)" }}>
|
||||||
@@ -146,6 +183,23 @@ export function WorkspaceDetail() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirmDelete}
|
||||||
|
title="Delete workspace"
|
||||||
|
description={`This will permanently delete workspace "${workspaceId}" and all its data. This cannot be undone.`}
|
||||||
|
confirmLabel="Delete workspace"
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
onCancel={() => setConfirmDelete(false)}
|
||||||
|
loading={deleteWorkspace.isPending}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ScheduleDreamModal
|
||||||
|
open={dreamOpen}
|
||||||
|
workspaceId={workspaceId}
|
||||||
|
onClose={() => setDreamOpen(false)}
|
||||||
|
mutation={scheduleDream}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { Route as WorkspacesRouteImport } from './routes/workspaces'
|
|||||||
import { Route as SettingsRouteImport } from './routes/settings'
|
import { Route as SettingsRouteImport } from './routes/settings'
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
import { Route as WorkspacesWorkspaceIdRouteImport } from './routes/workspaces_.$workspaceId'
|
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 WorkspacesWorkspaceIdSessionsRouteImport } from './routes/workspaces_.$workspaceId_.sessions'
|
||||||
import { Route as WorkspacesWorkspaceIdPeersRouteImport } from './routes/workspaces_.$workspaceId_.peers'
|
import { Route as WorkspacesWorkspaceIdPeersRouteImport } from './routes/workspaces_.$workspaceId_.peers'
|
||||||
import { Route as WorkspacesWorkspaceIdConclusionsRouteImport } from './routes/workspaces_.$workspaceId_.conclusions'
|
import { Route as WorkspacesWorkspaceIdConclusionsRouteImport } from './routes/workspaces_.$workspaceId_.conclusions'
|
||||||
@@ -40,6 +41,12 @@ const WorkspacesWorkspaceIdRoute = WorkspacesWorkspaceIdRouteImport.update({
|
|||||||
path: '/workspaces/$workspaceId',
|
path: '/workspaces/$workspaceId',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const WorkspacesWorkspaceIdWebhooksRoute =
|
||||||
|
WorkspacesWorkspaceIdWebhooksRouteImport.update({
|
||||||
|
id: '/workspaces_/$workspaceId_/webhooks',
|
||||||
|
path: '/workspaces/$workspaceId/webhooks',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const WorkspacesWorkspaceIdSessionsRoute =
|
const WorkspacesWorkspaceIdSessionsRoute =
|
||||||
WorkspacesWorkspaceIdSessionsRouteImport.update({
|
WorkspacesWorkspaceIdSessionsRouteImport.update({
|
||||||
id: '/workspaces_/$workspaceId_/sessions',
|
id: '/workspaces_/$workspaceId_/sessions',
|
||||||
@@ -85,6 +92,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/workspaces/$workspaceId/conclusions': typeof WorkspacesWorkspaceIdConclusionsRoute
|
'/workspaces/$workspaceId/conclusions': typeof WorkspacesWorkspaceIdConclusionsRoute
|
||||||
'/workspaces/$workspaceId/peers': typeof WorkspacesWorkspaceIdPeersRoute
|
'/workspaces/$workspaceId/peers': typeof WorkspacesWorkspaceIdPeersRoute
|
||||||
'/workspaces/$workspaceId/sessions': typeof WorkspacesWorkspaceIdSessionsRoute
|
'/workspaces/$workspaceId/sessions': typeof WorkspacesWorkspaceIdSessionsRoute
|
||||||
|
'/workspaces/$workspaceId/webhooks': typeof WorkspacesWorkspaceIdWebhooksRoute
|
||||||
'/workspaces/$workspaceId/peers/$peerId': typeof WorkspacesWorkspaceIdPeersPeerIdRoute
|
'/workspaces/$workspaceId/peers/$peerId': typeof WorkspacesWorkspaceIdPeersPeerIdRoute
|
||||||
'/workspaces/$workspaceId/sessions/$sessionId': typeof WorkspacesWorkspaceIdSessionsSessionIdRoute
|
'/workspaces/$workspaceId/sessions/$sessionId': typeof WorkspacesWorkspaceIdSessionsSessionIdRoute
|
||||||
'/workspaces/$workspaceId/peers/$peerId/chat': typeof WorkspacesWorkspaceIdPeersPeerIdChatRoute
|
'/workspaces/$workspaceId/peers/$peerId/chat': typeof WorkspacesWorkspaceIdPeersPeerIdChatRoute
|
||||||
@@ -97,6 +105,7 @@ export interface FileRoutesByTo {
|
|||||||
'/workspaces/$workspaceId/conclusions': typeof WorkspacesWorkspaceIdConclusionsRoute
|
'/workspaces/$workspaceId/conclusions': typeof WorkspacesWorkspaceIdConclusionsRoute
|
||||||
'/workspaces/$workspaceId/peers': typeof WorkspacesWorkspaceIdPeersRoute
|
'/workspaces/$workspaceId/peers': typeof WorkspacesWorkspaceIdPeersRoute
|
||||||
'/workspaces/$workspaceId/sessions': typeof WorkspacesWorkspaceIdSessionsRoute
|
'/workspaces/$workspaceId/sessions': typeof WorkspacesWorkspaceIdSessionsRoute
|
||||||
|
'/workspaces/$workspaceId/webhooks': typeof WorkspacesWorkspaceIdWebhooksRoute
|
||||||
'/workspaces/$workspaceId/peers/$peerId': typeof WorkspacesWorkspaceIdPeersPeerIdRoute
|
'/workspaces/$workspaceId/peers/$peerId': typeof WorkspacesWorkspaceIdPeersPeerIdRoute
|
||||||
'/workspaces/$workspaceId/sessions/$sessionId': typeof WorkspacesWorkspaceIdSessionsSessionIdRoute
|
'/workspaces/$workspaceId/sessions/$sessionId': typeof WorkspacesWorkspaceIdSessionsSessionIdRoute
|
||||||
'/workspaces/$workspaceId/peers/$peerId/chat': typeof WorkspacesWorkspaceIdPeersPeerIdChatRoute
|
'/workspaces/$workspaceId/peers/$peerId/chat': typeof WorkspacesWorkspaceIdPeersPeerIdChatRoute
|
||||||
@@ -110,6 +119,7 @@ export interface FileRoutesById {
|
|||||||
'/workspaces_/$workspaceId_/conclusions': typeof WorkspacesWorkspaceIdConclusionsRoute
|
'/workspaces_/$workspaceId_/conclusions': typeof WorkspacesWorkspaceIdConclusionsRoute
|
||||||
'/workspaces_/$workspaceId_/peers': typeof WorkspacesWorkspaceIdPeersRoute
|
'/workspaces_/$workspaceId_/peers': typeof WorkspacesWorkspaceIdPeersRoute
|
||||||
'/workspaces_/$workspaceId_/sessions': typeof WorkspacesWorkspaceIdSessionsRoute
|
'/workspaces_/$workspaceId_/sessions': typeof WorkspacesWorkspaceIdSessionsRoute
|
||||||
|
'/workspaces_/$workspaceId_/webhooks': typeof WorkspacesWorkspaceIdWebhooksRoute
|
||||||
'/workspaces_/$workspaceId_/peers_/$peerId': typeof WorkspacesWorkspaceIdPeersPeerIdRoute
|
'/workspaces_/$workspaceId_/peers_/$peerId': typeof WorkspacesWorkspaceIdPeersPeerIdRoute
|
||||||
'/workspaces_/$workspaceId_/sessions_/$sessionId': typeof WorkspacesWorkspaceIdSessionsSessionIdRoute
|
'/workspaces_/$workspaceId_/sessions_/$sessionId': typeof WorkspacesWorkspaceIdSessionsSessionIdRoute
|
||||||
'/workspaces_/$workspaceId_/peers_/$peerId_/chat': typeof WorkspacesWorkspaceIdPeersPeerIdChatRoute
|
'/workspaces_/$workspaceId_/peers_/$peerId_/chat': typeof WorkspacesWorkspaceIdPeersPeerIdChatRoute
|
||||||
@@ -124,6 +134,7 @@ export interface FileRouteTypes {
|
|||||||
| '/workspaces/$workspaceId/conclusions'
|
| '/workspaces/$workspaceId/conclusions'
|
||||||
| '/workspaces/$workspaceId/peers'
|
| '/workspaces/$workspaceId/peers'
|
||||||
| '/workspaces/$workspaceId/sessions'
|
| '/workspaces/$workspaceId/sessions'
|
||||||
|
| '/workspaces/$workspaceId/webhooks'
|
||||||
| '/workspaces/$workspaceId/peers/$peerId'
|
| '/workspaces/$workspaceId/peers/$peerId'
|
||||||
| '/workspaces/$workspaceId/sessions/$sessionId'
|
| '/workspaces/$workspaceId/sessions/$sessionId'
|
||||||
| '/workspaces/$workspaceId/peers/$peerId/chat'
|
| '/workspaces/$workspaceId/peers/$peerId/chat'
|
||||||
@@ -136,6 +147,7 @@ export interface FileRouteTypes {
|
|||||||
| '/workspaces/$workspaceId/conclusions'
|
| '/workspaces/$workspaceId/conclusions'
|
||||||
| '/workspaces/$workspaceId/peers'
|
| '/workspaces/$workspaceId/peers'
|
||||||
| '/workspaces/$workspaceId/sessions'
|
| '/workspaces/$workspaceId/sessions'
|
||||||
|
| '/workspaces/$workspaceId/webhooks'
|
||||||
| '/workspaces/$workspaceId/peers/$peerId'
|
| '/workspaces/$workspaceId/peers/$peerId'
|
||||||
| '/workspaces/$workspaceId/sessions/$sessionId'
|
| '/workspaces/$workspaceId/sessions/$sessionId'
|
||||||
| '/workspaces/$workspaceId/peers/$peerId/chat'
|
| '/workspaces/$workspaceId/peers/$peerId/chat'
|
||||||
@@ -148,6 +160,7 @@ export interface FileRouteTypes {
|
|||||||
| '/workspaces_/$workspaceId_/conclusions'
|
| '/workspaces_/$workspaceId_/conclusions'
|
||||||
| '/workspaces_/$workspaceId_/peers'
|
| '/workspaces_/$workspaceId_/peers'
|
||||||
| '/workspaces_/$workspaceId_/sessions'
|
| '/workspaces_/$workspaceId_/sessions'
|
||||||
|
| '/workspaces_/$workspaceId_/webhooks'
|
||||||
| '/workspaces_/$workspaceId_/peers_/$peerId'
|
| '/workspaces_/$workspaceId_/peers_/$peerId'
|
||||||
| '/workspaces_/$workspaceId_/sessions_/$sessionId'
|
| '/workspaces_/$workspaceId_/sessions_/$sessionId'
|
||||||
| '/workspaces_/$workspaceId_/peers_/$peerId_/chat'
|
| '/workspaces_/$workspaceId_/peers_/$peerId_/chat'
|
||||||
@@ -161,6 +174,7 @@ export interface RootRouteChildren {
|
|||||||
WorkspacesWorkspaceIdConclusionsRoute: typeof WorkspacesWorkspaceIdConclusionsRoute
|
WorkspacesWorkspaceIdConclusionsRoute: typeof WorkspacesWorkspaceIdConclusionsRoute
|
||||||
WorkspacesWorkspaceIdPeersRoute: typeof WorkspacesWorkspaceIdPeersRoute
|
WorkspacesWorkspaceIdPeersRoute: typeof WorkspacesWorkspaceIdPeersRoute
|
||||||
WorkspacesWorkspaceIdSessionsRoute: typeof WorkspacesWorkspaceIdSessionsRoute
|
WorkspacesWorkspaceIdSessionsRoute: typeof WorkspacesWorkspaceIdSessionsRoute
|
||||||
|
WorkspacesWorkspaceIdWebhooksRoute: typeof WorkspacesWorkspaceIdWebhooksRoute
|
||||||
WorkspacesWorkspaceIdPeersPeerIdRoute: typeof WorkspacesWorkspaceIdPeersPeerIdRoute
|
WorkspacesWorkspaceIdPeersPeerIdRoute: typeof WorkspacesWorkspaceIdPeersPeerIdRoute
|
||||||
WorkspacesWorkspaceIdSessionsSessionIdRoute: typeof WorkspacesWorkspaceIdSessionsSessionIdRoute
|
WorkspacesWorkspaceIdSessionsSessionIdRoute: typeof WorkspacesWorkspaceIdSessionsSessionIdRoute
|
||||||
WorkspacesWorkspaceIdPeersPeerIdChatRoute: typeof WorkspacesWorkspaceIdPeersPeerIdChatRoute
|
WorkspacesWorkspaceIdPeersPeerIdChatRoute: typeof WorkspacesWorkspaceIdPeersPeerIdChatRoute
|
||||||
@@ -196,6 +210,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof WorkspacesWorkspaceIdRouteImport
|
preLoaderRoute: typeof WorkspacesWorkspaceIdRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
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': {
|
'/workspaces_/$workspaceId_/sessions': {
|
||||||
id: '/workspaces_/$workspaceId_/sessions'
|
id: '/workspaces_/$workspaceId_/sessions'
|
||||||
path: '/workspaces/$workspaceId/sessions'
|
path: '/workspaces/$workspaceId/sessions'
|
||||||
@@ -249,6 +270,7 @@ const rootRouteChildren: RootRouteChildren = {
|
|||||||
WorkspacesWorkspaceIdConclusionsRoute: WorkspacesWorkspaceIdConclusionsRoute,
|
WorkspacesWorkspaceIdConclusionsRoute: WorkspacesWorkspaceIdConclusionsRoute,
|
||||||
WorkspacesWorkspaceIdPeersRoute: WorkspacesWorkspaceIdPeersRoute,
|
WorkspacesWorkspaceIdPeersRoute: WorkspacesWorkspaceIdPeersRoute,
|
||||||
WorkspacesWorkspaceIdSessionsRoute: WorkspacesWorkspaceIdSessionsRoute,
|
WorkspacesWorkspaceIdSessionsRoute: WorkspacesWorkspaceIdSessionsRoute,
|
||||||
|
WorkspacesWorkspaceIdWebhooksRoute: WorkspacesWorkspaceIdWebhooksRoute,
|
||||||
WorkspacesWorkspaceIdPeersPeerIdRoute: WorkspacesWorkspaceIdPeersPeerIdRoute,
|
WorkspacesWorkspaceIdPeersPeerIdRoute: WorkspacesWorkspaceIdPeersPeerIdRoute,
|
||||||
WorkspacesWorkspaceIdSessionsSessionIdRoute:
|
WorkspacesWorkspaceIdSessionsSessionIdRoute:
|
||||||
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