feat: restructure as pnpm monorepo with Tauri desktop shell
- Migrate to packages/web + packages/desktop workspace layout via git mv - Add Tauri v2 desktop shell with @tauri-apps/plugin-http for CORS bypass - Configure Turborepo with package-level dependsOn build graph - Add semantic-release with exec plugin for GHA output and disabled PR comments - Fix http:default capability scope to allow all HTTP/HTTPS origins - Add Vite Tauri integration (clearScreen, TAURI_DEV_HOST, target, envPrefix) - Add semantic-release.yml and release.yml GitHub Actions workflows - Fix all Biome lint errors (noArrayIndexKey, noNonNullAssertion, button types)
This commit is contained in:
134
packages/web/src/components/workspaces/ScheduleDreamModal.tsx
Normal file
134
packages/web/src/components/workspaces/ScheduleDreamModal.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import { FormModal } from "@/components/shared/FormModal";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Caption } from "@/components/ui/typography";
|
||||
import { COLOR } from "@/lib/constants";
|
||||
import type { UseMutationResult } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
const schema = z.object({
|
||||
observer: z.string().min(1, "Observer peer ID is required"),
|
||||
observed: z.string().optional(),
|
||||
session_id: z.string().optional(),
|
||||
});
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
workspaceId: string;
|
||||
onClose: () => void;
|
||||
mutation: UseMutationResult<
|
||||
void,
|
||||
Error,
|
||||
{ observer: string; observed?: string | null; dream_type: "omni"; session_id?: string | null }
|
||||
>;
|
||||
}
|
||||
|
||||
export function ScheduleDreamModal({ open, onClose, mutation }: Props) {
|
||||
const [observer, setObserver] = useState("");
|
||||
const [observed, setObserved] = useState("");
|
||||
const [sessionId, setSessionId] = useState("");
|
||||
const [validationError, setValidationError] = useState("");
|
||||
|
||||
const reset = () => {
|
||||
setObserver("");
|
||||
setObserved("");
|
||||
setSessionId("");
|
||||
setValidationError("");
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.SyntheticEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const result = schema.safeParse({
|
||||
observer,
|
||||
observed: observed || undefined,
|
||||
session_id: sessionId || undefined,
|
||||
});
|
||||
if (!result.success) {
|
||||
setValidationError(result.error.errors[0].message);
|
||||
return;
|
||||
}
|
||||
await mutation.mutateAsync({
|
||||
observer: result.data.observer,
|
||||
observed: result.data.observed ?? null,
|
||||
dream_type: "omni",
|
||||
session_id: result.data.session_id ?? null,
|
||||
});
|
||||
reset();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<FormModal
|
||||
open={open}
|
||||
title="Schedule Dream"
|
||||
onClose={() => {
|
||||
reset();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<Label className="mb-1.5">
|
||||
Observer peer ID <span style={{ color: COLOR.destructive }}>*</span>
|
||||
</Label>
|
||||
<Input
|
||||
value={observer}
|
||||
onChange={(e) => {
|
||||
setObserver(e.target.value);
|
||||
setValidationError("");
|
||||
}}
|
||||
placeholder="peer_id"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1.5">
|
||||
Observed peer ID <Caption as="span"> (optional, defaults to observer)</Caption>
|
||||
</Label>
|
||||
<Input
|
||||
value={observed}
|
||||
onChange={(e) => setObserved(e.target.value)}
|
||||
placeholder="peer_id"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1.5">
|
||||
Session ID <Caption as="span"> (optional)</Caption>
|
||||
</Label>
|
||||
<Input
|
||||
value={sessionId}
|
||||
onChange={(e) => setSessionId(e.target.value)}
|
||||
placeholder="session_id"
|
||||
/>
|
||||
</div>
|
||||
{validationError && (
|
||||
<Caption as="p" style={{ color: COLOR.destructive }}>
|
||||
{validationError}
|
||||
</Caption>
|
||||
)}
|
||||
{mutation.error && (
|
||||
<Caption as="p" style={{ color: COLOR.destructive }}>
|
||||
{mutation.error.message}
|
||||
</Caption>
|
||||
)}
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="surface"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
reset();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="accent" size="sm" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? "Scheduling..." : "Schedule"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</FormModal>
|
||||
);
|
||||
}
|
||||
217
packages/web/src/components/workspaces/WebhookManager.tsx
Normal file
217
packages/web/src/components/workspaces/WebhookManager.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
import { useCreateWebhook, useDeleteWebhook, useTestWebhook, useWebhooks } from "@/api/queries";
|
||||
import { ConfirmDialog } from "@/components/shared/ConfirmDialog";
|
||||
import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
||||
import { PageLoader } from "@/components/shared/LoadingSpinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Body, Muted, PageTitle, SectionHeading } from "@/components/ui/typography";
|
||||
import { COLOR } from "@/lib/constants";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { open } from "@tauri-apps/plugin-shell";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { ArrowLeft, ExternalLink, Plus, Trash2, Webhook, Zap } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
const urlSchema = z.string().url("Must be a valid URL");
|
||||
|
||||
interface Props {
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export function WebhookManager({ workspaceId }: Props) {
|
||||
const { data: webhooks, isLoading, error } = useWebhooks(workspaceId);
|
||||
const createWebhook = useCreateWebhook(workspaceId);
|
||||
const deleteWebhook = useDeleteWebhook(workspaceId);
|
||||
const testWebhook = useTestWebhook(workspaceId);
|
||||
|
||||
const [url, setUrl] = useState("");
|
||||
const [urlError, setUrlError] = useState("");
|
||||
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
||||
const [testResult, setTestResult] = useState<string | null>(null);
|
||||
|
||||
const handleCreate = async (e: React.SyntheticEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const result = urlSchema.safeParse(url);
|
||||
if (!result.success) {
|
||||
setUrlError(result.error.errors[0].message);
|
||||
return;
|
||||
}
|
||||
await createWebhook.mutateAsync(url);
|
||||
setUrl("");
|
||||
setUrlError("");
|
||||
};
|
||||
|
||||
const handleTest = async () => {
|
||||
const data = await testWebhook.mutateAsync();
|
||||
setTestResult(JSON.stringify(data, null, 2));
|
||||
setTimeout(() => setTestResult(null), 5000);
|
||||
};
|
||||
|
||||
const list = Array.isArray(webhooks) ? webhooks : [];
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }}>
|
||||
<Link
|
||||
to="/workspaces/$workspaceId"
|
||||
params={{ workspaceId } as never}
|
||||
className="inline-flex items-center gap-1.5 text-xs mb-4 transition-colors"
|
||||
style={{ color: "var(--text-3)" }}
|
||||
>
|
||||
<ArrowLeft className="w-3 h-3" strokeWidth={1.5} />
|
||||
{workspaceId}
|
||||
</Link>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Webhook className="w-5 h-5" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
|
||||
<PageTitle>Webhooks</PageTitle>
|
||||
</div>
|
||||
<Button variant="accent" size="sm" onClick={handleTest} disabled={testWebhook.isPending}>
|
||||
<Zap className="w-3.5 h-3.5" strokeWidth={2} />
|
||||
{testWebhook.isPending ? "Firing..." : "Test emit"}
|
||||
</Button>
|
||||
</div>
|
||||
<Body className="leading-none">Event webhook endpoints for this workspace</Body>
|
||||
</motion.div>
|
||||
|
||||
<div className="mt-8 space-y-4">
|
||||
<ErrorAlert error={error instanceof Error ? error : null} />
|
||||
{isLoading && <PageLoader />}
|
||||
|
||||
{/* Add webhook form */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="rounded-xl p-5 theme-card"
|
||||
>
|
||||
<SectionHeading>
|
||||
<Plus className="w-3.5 h-3.5 inline mr-1.5" strokeWidth={2} />
|
||||
Add endpoint
|
||||
</SectionHeading>
|
||||
<form onSubmit={handleCreate} className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
value={url}
|
||||
onChange={(e) => {
|
||||
setUrl(e.target.value);
|
||||
setUrlError("");
|
||||
}}
|
||||
placeholder="https://your-server.com/webhook"
|
||||
/>
|
||||
{urlError && (
|
||||
<p className="text-xs mt-1" style={{ color: COLOR.destructive }}>
|
||||
{urlError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button type="submit" variant="accent" disabled={createWebhook.isPending}>
|
||||
{createWebhook.isPending ? "Adding..." : "Add"}
|
||||
</Button>
|
||||
</form>
|
||||
</motion.div>
|
||||
|
||||
{/* Test result */}
|
||||
<AnimatePresence>
|
||||
{testResult && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="rounded-xl p-4 text-xs font-mono overflow-auto"
|
||||
style={{
|
||||
background: COLOR.successDim,
|
||||
border: `1px solid ${COLOR.successBorder}`,
|
||||
color: COLOR.success,
|
||||
}}
|
||||
>
|
||||
{testResult}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Webhook list */}
|
||||
{!isLoading && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="rounded-xl theme-card overflow-hidden"
|
||||
>
|
||||
{list.length === 0 ? (
|
||||
<div className="p-8 text-center">
|
||||
<Webhook
|
||||
className="w-8 h-8 mx-auto mb-2 opacity-20"
|
||||
style={{ color: "var(--text-3)" }}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<Muted>No webhook endpoints yet.</Muted>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y" style={{ borderColor: "var(--border)" }}>
|
||||
{list.map((wh, i) => (
|
||||
<motion.div
|
||||
key={(wh as { id: string }).id}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: i * 0.04 }}
|
||||
className="flex items-center justify-between px-5 py-3 gap-4"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="text-xs font-mono truncate"
|
||||
style={{ color: "var(--accent-text)" }}
|
||||
>
|
||||
{(wh as { url: string }).url}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void open((wh as { url: string }).url)}
|
||||
className="flex-shrink-0"
|
||||
style={{
|
||||
color: "var(--text-4)",
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-xs font-mono" style={{ color: "var(--text-4)" }}>
|
||||
{(wh as { id: string }).id}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setDeleteTarget((wh as { id: string }).id)}
|
||||
aria-label="Delete webhook"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(deleteTarget)}
|
||||
title="Delete webhook"
|
||||
description="This endpoint will stop receiving events immediately."
|
||||
confirmLabel="Delete"
|
||||
onConfirm={async () => {
|
||||
if (deleteTarget) await deleteWebhook.mutateAsync(deleteTarget);
|
||||
setDeleteTarget(null);
|
||||
}}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
loading={deleteWebhook.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
342
packages/web/src/components/workspaces/WorkspaceDetail.tsx
Normal file
342
packages/web/src/components/workspaces/WorkspaceDetail.tsx
Normal file
@@ -0,0 +1,342 @@
|
||||
import { useDeleteWorkspace, useQueueStatus, useScheduleDream, useWorkspace } from "@/api/queries";
|
||||
import { ConfirmDialog } from "@/components/shared/ConfirmDialog";
|
||||
import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
||||
import { JsonViewer } from "@/components/shared/JsonViewer";
|
||||
import { PageLoader } from "@/components/shared/LoadingSpinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Body, Caption, PageTitle, SectionHeading } from "@/components/ui/typography";
|
||||
import { ScheduleDreamModal } from "@/components/workspaces/ScheduleDreamModal";
|
||||
import { COLOR } from "@/lib/constants";
|
||||
import { Link, useNavigate, useParams } from "@tanstack/react-router";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Boxes,
|
||||
ChevronDown,
|
||||
CircleDot,
|
||||
Lightbulb,
|
||||
MessageSquare,
|
||||
Trash2,
|
||||
Users,
|
||||
Webhook,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
const NAV_SECTIONS = [
|
||||
{
|
||||
label: "Peers",
|
||||
icon: Users,
|
||||
to: "peers" as const,
|
||||
description: "Browse peer identities and memory",
|
||||
},
|
||||
{
|
||||
label: "Sessions",
|
||||
icon: MessageSquare,
|
||||
to: "sessions" as const,
|
||||
description: "View conversation sessions",
|
||||
},
|
||||
{
|
||||
label: "Conclusions",
|
||||
icon: Lightbulb,
|
||||
to: "conclusions" as const,
|
||||
description: "Browse memory conclusions",
|
||||
},
|
||||
{
|
||||
label: "Webhooks",
|
||||
icon: Webhook,
|
||||
to: "webhooks" as const,
|
||||
description: "Manage event webhooks",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function WorkspaceDetail() {
|
||||
const { workspaceId } = useParams({ strict: false }) as { workspaceId: string };
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data: workspace, isLoading, error } = useWorkspace(workspaceId);
|
||||
const { data: queue } = useQueueStatus(workspaceId);
|
||||
|
||||
const deleteWorkspace = useDeleteWorkspace();
|
||||
const scheduleDream = useScheduleDream(workspaceId);
|
||||
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [dreamOpen, setDreamOpen] = useState(false);
|
||||
const [sessionsExpanded, setSessionsExpanded] = useState(false);
|
||||
|
||||
const handleDelete = async () => {
|
||||
await deleteWorkspace.mutateAsync(workspaceId);
|
||||
navigate({ to: "/workspaces" as never });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-container page-container--wide">
|
||||
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }}>
|
||||
<Link
|
||||
to="/workspaces"
|
||||
className="inline-flex items-center gap-1.5 text-xs mb-4 transition-colors"
|
||||
style={{ color: "var(--text-3)" }}
|
||||
>
|
||||
<ArrowLeft className="w-3 h-3" strokeWidth={1.5} />
|
||||
Workspaces
|
||||
</Link>
|
||||
<div className="flex items-start justify-between gap-4 mb-1">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Boxes
|
||||
className="w-5 h-5 flex-shrink-0"
|
||||
style={{ color: "var(--accent)" }}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<PageTitle className="font-mono break-all">{workspaceId}</PageTitle>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<Button variant="accent" size="sm" onClick={() => setDreamOpen(true)}>
|
||||
<Zap className="w-3.5 h-3.5" strokeWidth={2} />
|
||||
Schedule Dream
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onClick={() => setConfirmDelete(true)}>
|
||||
<Trash2 className="w-3.5 h-3.5" strokeWidth={2} />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Body className="leading-none">Workspace overview</Body>
|
||||
</motion.div>
|
||||
|
||||
<div className="mt-8">
|
||||
<ErrorAlert error={error instanceof Error ? error : null} />
|
||||
{isLoading && <PageLoader />}
|
||||
|
||||
{!isLoading && workspace && (
|
||||
<div className="space-y-4">
|
||||
{/* Nav cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
{NAV_SECTIONS.map((s, i) => {
|
||||
const Icon = s.icon;
|
||||
return (
|
||||
<motion.div
|
||||
key={s.to}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.06, type: "spring", stiffness: 300, damping: 25 }}
|
||||
>
|
||||
<Link
|
||||
to={`/workspaces/$workspaceId/${s.to}` as never}
|
||||
params={{ workspaceId } as never}
|
||||
className="block rounded-xl p-5 group transition-all theme-card"
|
||||
>
|
||||
<Icon
|
||||
className="w-5 h-5 mb-3"
|
||||
style={{ color: "var(--accent)" }}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<SectionHeading className="mb-0.5">{s.label}</SectionHeading>
|
||||
<Caption as="p">{s.description}</Caption>
|
||||
</Link>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Queue status */}
|
||||
{queue && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.28 }}
|
||||
className="rounded-xl p-5 theme-card"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<SectionHeading className="mb-0">Queue Status</SectionHeading>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{queue.pending_work_units > 0 ? (
|
||||
<motion.div
|
||||
animate={{ opacity: [0.5, 1, 0.5] }}
|
||||
transition={{ duration: 1.5, repeat: Number.POSITIVE_INFINITY }}
|
||||
>
|
||||
<CircleDot
|
||||
className="w-3.5 h-3.5"
|
||||
style={{ color: COLOR.warning }}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</motion.div>
|
||||
) : (
|
||||
<CircleDot
|
||||
className="w-3.5 h-3.5"
|
||||
style={{ color: COLOR.success }}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className="text-xs font-medium"
|
||||
style={{
|
||||
color: queue.pending_work_units > 0 ? COLOR.warning : COLOR.success,
|
||||
}}
|
||||
>
|
||||
{queue.pending_work_units === 0
|
||||
? "Idle"
|
||||
: `${queue.pending_work_units} pending`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-4">
|
||||
{(
|
||||
[
|
||||
"total_work_units",
|
||||
"completed_work_units",
|
||||
"in_progress_work_units",
|
||||
"pending_work_units",
|
||||
] as const
|
||||
).map((key) => (
|
||||
<div key={key}>
|
||||
<div
|
||||
className="text-2xl font-semibold font-mono"
|
||||
style={{ color: "var(--text-1)" }}
|
||||
>
|
||||
{queue[key]}
|
||||
</div>
|
||||
<div className="text-xs capitalize mt-0.5" style={{ color: "var(--text-3)" }}>
|
||||
{key.replace(/_work_units$/, "").replace(/_/g, " ")}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Per-session breakdown */}
|
||||
{queue.sessions && Object.keys(queue.sessions).length > 0 && (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSessionsExpanded((v) => !v)}
|
||||
className="flex items-center gap-1.5 text-xs font-medium w-full text-left"
|
||||
style={{ color: "var(--text-3)" }}
|
||||
>
|
||||
<motion.div
|
||||
animate={{ rotate: sessionsExpanded ? 0 : -90 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<ChevronDown className="w-3.5 h-3.5" strokeWidth={2} />
|
||||
</motion.div>
|
||||
{Object.keys(queue.sessions).length} session
|
||||
{Object.keys(queue.sessions).length !== 1 ? "s" : ""}
|
||||
</button>
|
||||
<AnimatePresence initial={false}>
|
||||
{sessionsExpanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div
|
||||
className="mt-3 rounded-lg overflow-hidden"
|
||||
style={{ border: "1px solid var(--border)" }}
|
||||
>
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr
|
||||
style={{
|
||||
background: "var(--bg-3)",
|
||||
borderBottom: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
{["Session", "Total", "Done", "Active", "Pending"].map((h) => (
|
||||
<th
|
||||
key={h}
|
||||
className={`py-2 px-3 font-medium text-left ${h !== "Session" ? "text-right" : ""}`}
|
||||
style={{ color: "var(--text-3)" }}
|
||||
>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(queue.sessions).map(([sid, s], i) => (
|
||||
<tr
|
||||
key={sid}
|
||||
style={{
|
||||
borderTop: i > 0 ? "1px solid var(--border)" : undefined,
|
||||
}}
|
||||
>
|
||||
<td className="py-1.5 px-3">
|
||||
<Link
|
||||
to={"/workspaces/$workspaceId/sessions/$sessionId" as never}
|
||||
params={{ workspaceId, sessionId: sid } as never}
|
||||
className="font-mono truncate block max-w-[180px] hover:underline"
|
||||
style={{ color: "var(--accent-text)" }}
|
||||
>
|
||||
{sid}
|
||||
</Link>
|
||||
</td>
|
||||
<td
|
||||
className="py-1.5 px-3 text-right font-mono"
|
||||
style={{ color: "var(--text-2)" }}
|
||||
>
|
||||
{s.total_work_units}
|
||||
</td>
|
||||
<td
|
||||
className="py-1.5 px-3 text-right font-mono"
|
||||
style={{ color: COLOR.success }}
|
||||
>
|
||||
{s.completed_work_units}
|
||||
</td>
|
||||
<td
|
||||
className="py-1.5 px-3 text-right font-mono"
|
||||
style={{ color: COLOR.warning }}
|
||||
>
|
||||
{s.in_progress_work_units}
|
||||
</td>
|
||||
<td
|
||||
className="py-1.5 px-3 text-right font-mono"
|
||||
style={{ color: "var(--text-3)" }}
|
||||
>
|
||||
{s.pending_work_units}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Metadata */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.38 }}
|
||||
className="rounded-xl p-5 theme-card"
|
||||
>
|
||||
<SectionHeading>Metadata</SectionHeading>
|
||||
<JsonViewer data={workspace.metadata} />
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmDelete}
|
||||
title="Delete workspace"
|
||||
description={`This will permanently delete workspace "${workspaceId}" and all its data. This cannot be undone.`}
|
||||
confirmLabel="Delete workspace"
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => setConfirmDelete(false)}
|
||||
loading={deleteWorkspace.isPending}
|
||||
/>
|
||||
|
||||
<ScheduleDreamModal
|
||||
open={dreamOpen}
|
||||
workspaceId={workspaceId}
|
||||
onClose={() => setDreamOpen(false)}
|
||||
mutation={scheduleDream}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
156
packages/web/src/components/workspaces/WorkspaceList.tsx
Normal file
156
packages/web/src/components/workspaces/WorkspaceList.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import { useWorkspaces } from "@/api/queries";
|
||||
import type { components } from "@/api/schema.d.ts";
|
||||
import { EmptyState } from "@/components/shared/EmptyState";
|
||||
import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
||||
import { PageLoader } from "@/components/shared/LoadingSpinner";
|
||||
import { Pagination } from "@/components/shared/Pagination";
|
||||
import { SortControl, type SortDir } from "@/components/shared/SortControl";
|
||||
import { MonoCaption, Muted, PageTitle } from "@/components/ui/typography";
|
||||
import { COLOR } from "@/lib/constants";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { type Variants, motion } from "framer-motion";
|
||||
import { Boxes, ChevronRight, Clock } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
type Workspace = components["schemas"]["Workspace"];
|
||||
|
||||
const SORT_OPTIONS = [
|
||||
{ value: "created_at", label: "Newest" },
|
||||
{ value: "id", label: "ID" },
|
||||
];
|
||||
|
||||
const container: Variants = {
|
||||
hidden: { opacity: 0 },
|
||||
show: { opacity: 1, transition: { staggerChildren: 0.06 } },
|
||||
};
|
||||
const item: Variants = {
|
||||
hidden: { opacity: 0, y: 12 },
|
||||
show: { opacity: 1, y: 0, transition: { type: "spring", stiffness: 300, damping: 25 } },
|
||||
};
|
||||
|
||||
export function WorkspaceList() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [sortField, setSortField] = useState("created_at");
|
||||
const [sortDir, setSortDir] = useState<SortDir>("desc");
|
||||
const navigate = useNavigate();
|
||||
const { data, isLoading, error } = useWorkspaces(page);
|
||||
|
||||
const workspaces: Workspace[] = (data as { items?: Workspace[] } | undefined)?.items ?? [];
|
||||
const totalPages = (data as { pages?: number } | undefined)?.pages ?? 1;
|
||||
const total = (data as { total?: number } | undefined)?.total ?? 0;
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
return [...workspaces].sort((a, b) => {
|
||||
let cmp = 0;
|
||||
if (sortField === "created_at") {
|
||||
cmp = new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
|
||||
} else if (sortField === "id") {
|
||||
cmp = a.id.localeCompare(b.id);
|
||||
}
|
||||
return sortDir === "asc" ? cmp : -cmp;
|
||||
});
|
||||
}, [workspaces, sortField, sortDir]);
|
||||
|
||||
function handleSort(field: string, dir: SortDir) {
|
||||
setSortField(field);
|
||||
setSortDir(dir);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.35 }}
|
||||
className="mb-6"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Boxes className="w-5 h-5" style={{ color: COLOR.accent }} strokeWidth={1.5} />
|
||||
<PageTitle>Workspaces</PageTitle>
|
||||
{total > 0 && (
|
||||
<span
|
||||
className="ml-1 text-xs font-mono px-2 py-0.5 rounded-full"
|
||||
style={{
|
||||
background: COLOR.accentSubtle,
|
||||
color: COLOR.accentText,
|
||||
border: `1px solid ${COLOR.accentBorder}`,
|
||||
}}
|
||||
>
|
||||
{total}
|
||||
</span>
|
||||
)}
|
||||
<div className="ml-auto">
|
||||
<SortControl
|
||||
options={SORT_OPTIONS}
|
||||
field={sortField}
|
||||
dir={sortDir}
|
||||
onChange={handleSort}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Muted>All workspaces in your Honcho instance</Muted>
|
||||
</motion.div>
|
||||
|
||||
<ErrorAlert error={error instanceof Error ? error : null} />
|
||||
{isLoading && <PageLoader />}
|
||||
|
||||
{!isLoading && workspaces.length === 0 && (
|
||||
<EmptyState
|
||||
icon={Boxes}
|
||||
title="No workspaces found"
|
||||
description="No workspaces exist yet in this Honcho instance."
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isLoading && sorted.length > 0 && (
|
||||
<>
|
||||
<motion.div variants={container} initial="hidden" animate="show" className="space-y-2">
|
||||
{sorted.map((ws) => (
|
||||
<motion.button
|
||||
key={ws.id}
|
||||
variants={item}
|
||||
onClick={() =>
|
||||
navigate({
|
||||
to: "/workspaces/$workspaceId",
|
||||
params: { workspaceId: ws.id } as never,
|
||||
})
|
||||
}
|
||||
className="w-full text-left rounded-xl px-5 py-4 group transition-all"
|
||||
style={{
|
||||
background: COLOR.cardBaseBg,
|
||||
border: `1px solid ${COLOR.cardBaseBorder}`,
|
||||
}}
|
||||
whileHover={{
|
||||
background: COLOR.accentDimHover,
|
||||
borderColor: COLOR.accentBorder,
|
||||
x: 2,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span
|
||||
className="font-mono text-sm font-medium"
|
||||
style={{ color: COLOR.accentSoft }}
|
||||
>
|
||||
{ws.id}
|
||||
</span>
|
||||
<ChevronRight
|
||||
className="w-4 h-4 opacity-30 group-hover:opacity-70 transition-opacity"
|
||||
style={{ color: COLOR.accent }}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</div>
|
||||
{ws.created_at && (
|
||||
<div className="flex items-center gap-1.5 mt-2">
|
||||
<Clock className="w-3 h-3" style={{ color: COLOR.dimIcon }} strokeWidth={1.5} />
|
||||
<MonoCaption>{new Date(ws.created_at).toLocaleString()}</MonoCaption>
|
||||
</div>
|
||||
)}
|
||||
</motion.button>
|
||||
))}
|
||||
</motion.div>
|
||||
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user