feat: add server error logging and tighten workspace isolation

This commit is contained in:
2026-08-10 12:41:46 +00:00
parent 6449f6b4cc
commit 1059512888
48 changed files with 1096 additions and 229 deletions
@@ -40,6 +40,9 @@ export function TagManager({ entityType, entityId, tags }: TagManagerProps) {
const availableTags = allTags.filter((t) => !assignedIds.has(t.id));
const refreshEntity = () => {
// Refresh the list view (["tasks", ...], ["habits", ...], ["notes", ...])
// and the detail view (["task", id], ...) so badges stay in sync in both.
queryClient.invalidateQueries({ queryKey: [plural] });
queryClient.invalidateQueries({ queryKey: [entityType, entityId] });
};
@@ -1,5 +1,5 @@
import { useEffect, useState, useCallback, useRef } from "react";
import { useNavigate } from "@tanstack/react-router";
import { useNavigate, useLocation } from "@tanstack/react-router";
import {
LayoutDashboard,
ListTodo,
@@ -28,6 +28,8 @@ import {
CommandSeparator,
} from "@/components/ui/command";
import { useThemeStore, type AccentColor, ACCENT_PALETTE } from "@/lib/stores/use-theme-store";
import { useCreateDialogStore } from "@/lib/stores/use-create-dialog-store";
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
interface NavItem {
label: string;
@@ -70,7 +72,9 @@ function addRecentPage(href: string) {
export function CommandPalette() {
const navigate = useNavigate();
const location = useLocation();
const { mode, setMode, accent, setAccent } = useThemeStore();
const activeDomainId = useApiDomain();
const [open, setOpen] = useState(false);
const [searchResults, setSearchResults] = useState<
Array<{ type: string; items: Array<{ id: string; title: string; link?: string }> }>
@@ -94,36 +98,50 @@ export function CommandPalette() {
// Track page navigation for recent items
useEffect(() => {
const path = window.location.pathname;
if (path !== "/login") addRecentPage(path);
}, []);
if (location.pathname !== "/login") addRecentPage(location.pathname);
}, [location.pathname]);
// Quick actions
const quickActions: QuickAction[] = [
{
label: "New task",
icon: ListTodo,
action: () => navigate({ to: "/tasks" }),
action: () => {
useCreateDialogStore.getState().openCreate("task");
navigate({ to: "/tasks" });
},
},
{
label: "New habit",
icon: Flame,
action: () => navigate({ to: "/habits" }),
action: () => {
useCreateDialogStore.getState().openCreate("habit");
navigate({ to: "/habits" });
},
},
{
label: "New project",
icon: FolderKanban,
action: () => navigate({ to: "/projects" }),
action: () => {
useCreateDialogStore.getState().openCreate("project");
navigate({ to: "/projects" });
},
},
{
label: "New note",
icon: NotebookPen,
action: () => navigate({ to: "/notes" }),
action: () => {
useCreateDialogStore.getState().openCreate("note");
navigate({ to: "/notes" });
},
},
{
label: "New event",
icon: CalendarDays,
action: () => navigate({ to: "/calendar" }),
action: () => {
useCreateDialogStore.getState().openCreate("event");
navigate({ to: "/calendar" });
},
},
];
@@ -172,7 +190,7 @@ export function CommandPalette() {
const mentionQuery = query.slice(1).trim();
if (mentionQuery) {
try {
const res = await fetch(`/api/agents?q=${encodeURIComponent(mentionQuery)}`);
const res = await fetch(`/api/agents?q=${encodeURIComponent(mentionQuery)}` + (activeDomainId ? "&domain=" + activeDomainId : ""));
if (res.ok) {
const data = await res.json();
setSearchResults([
@@ -195,16 +213,24 @@ export function CommandPalette() {
// Debounced API search
searchTimeoutRef.current = setTimeout(async () => {
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}&limit=5`);
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}&limit=5` + (activeDomainId ? "&domain=" + activeDomainId : ""));
if (res.ok) {
const data = await res.json();
setSearchResults(data.results || []);
// The API returns a flat list of SearchResult objects; group them by
// entity type for the grouped render below.
const flat: Array<{ type: string; id: string; title: string; link?: string }> = data.results || [];
const grouped: Record<string, Array<{ id: string; title: string; link?: string }>> = {};
for (const r of flat) {
const key = r.type.charAt(0).toUpperCase() + r.type.slice(1) + "s";
(grouped[key] = grouped[key] || []).push({ id: r.id, title: r.title, link: r.link });
}
setSearchResults(Object.entries(grouped).map(([type, items]) => ({ type, items })));
}
} catch {
// Ignore search errors
}
}, 300);
}, []);
}, [activeDomainId]);
const runCommand = useCallback(
(command: () => void) => {
@@ -320,7 +346,7 @@ export function CommandPalette() {
runCommand(() => {});
return;
}
const link = group.type === "domain" ? "/" : item.link!;
const link = group.type === "Domains" ? "/" : item.link!;
runCommand(() => navigate({ to: link }));
}}
>
@@ -28,14 +28,15 @@ const shortcutGroups = [
{ keys: "n then h", description: "New habit" },
{ keys: "n then p", description: "New project" },
{ keys: "n then n", description: "New note" },
{ keys: "c", description: "Focus create in palette" },
{ keys: "⌘N / Ctrl+N", description: "New task" },
],
},
{
heading: "General",
shortcuts: [
{ keys: "⌘K / Ctrl+K", description: "Open command palette" },
{ keys: "/", description: "Focus search" },
{ keys: "/", description: "Open command palette" },
{ keys: "c", description: "Open command palette" },
{ keys: "?", description: "Show this help" },
{ keys: "Esc", description: "Close dialogs / panels" },
],
+15 -6
View File
@@ -1,7 +1,8 @@
import { useEffect, useState } from "react";
import { Link, useLocation } from "@tanstack/react-router";
import { Link, useLocation, useNavigate } from "@tanstack/react-router";
import { cn } from "@/lib/utils";
import { useSidebarStore } from "@/lib/stores/use-sidebar-store";
import { useAuthStore } from "@/lib/stores/use-auth-store";
import {
LayoutDashboard,
ListTodo,
@@ -46,6 +47,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { DomainPicker } from "@/components/shell/domain-picker";
interface NavItem {
href: string;
@@ -75,7 +77,11 @@ const bottomItems: NavItem[] = [
export function Sidebar() {
const location = useLocation();
const navigate = useNavigate();
const { collapsed, toggle, mobileOpen, setMobileOpen } = useSidebarStore();
const user = useAuthStore((s) => s.user);
const userName = user?.name || user?.email?.split("@")[0] || "User";
const userInitials = (user?.name || user?.email || "U").slice(0, 2).toUpperCase();
// Sidebar position (left/right) is set in Settings. Read once on mount and
// update live via the "sidebar-position-change" custom event dispatched by
@@ -204,12 +210,12 @@ export function Sidebar() {
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-10 w-10">
<Avatar className="h-8 w-8">
<AvatarFallback className="text-xs">U</AvatarFallback>
<AvatarFallback className="text-xs">{userInitials}</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent side={sidebarPos === "right" ? "left" : "right"} align="start" className="w-48">
<DropdownMenuItem onClick={() => {}}>
<DropdownMenuItem onClick={() => navigate({ to: "/settings" })}>
<User className="mr-2 h-4 w-4" />
Profile
</DropdownMenuItem>
@@ -229,13 +235,13 @@ export function Sidebar() {
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="w-full justify-start gap-3 px-3">
<Avatar className="h-8 w-8">
<AvatarFallback className="text-xs">U</AvatarFallback>
<AvatarFallback className="text-xs">{userInitials}</AvatarFallback>
</Avatar>
<span className="text-sm font-medium">User</span>
<span className="text-sm font-medium truncate">{userName}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent side={sidebarPos === "right" ? "left" : "right"} align="start" className="w-48">
<DropdownMenuItem onClick={() => {}}>
<DropdownMenuItem onClick={() => navigate({ to: "/settings" })}>
<User className="mr-2 h-4 w-4" />
Profile
</DropdownMenuItem>
@@ -261,6 +267,9 @@ export function Sidebar() {
<SheetTitle>Project E</SheetTitle>
<SheetDescription>Navigate your workspace.</SheetDescription>
</SheetHeader>
<div className="border-b p-4">
<DomainPicker />
</div>
{navigation(false, () => setMobileOpen(false))}
</SheetContent>
</Sheet>
+11 -4
View File
@@ -2,7 +2,9 @@ import { Search, Bell, Plus, Menu, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useSidebarStore } from "@/lib/stores/use-sidebar-store";
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
import { useAuthStore } from "@/lib/stores/use-auth-store";
import { useApiQuery } from "@/lib/api";
import { useNavigate } from "@tanstack/react-router";
import { useQueryClient } from "@tanstack/react-query";
import { DomainPicker } from "@/components/shell/domain-picker";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
@@ -38,7 +40,12 @@ function readableEntityType(entityType: string): string {
export function Topbar() {
const { setMobileOpen } = useSidebarStore();
const queryClient = useQueryClient();
const navigate = useNavigate();
const domainId = useApiDomain();
const user = useAuthStore((s) => s.user);
const userName = user?.name || "User";
const userEmail = user?.email || "";
const userInitials = (user?.name || user?.email || "U").slice(0, 2).toUpperCase();
const openPalette = () => {
document.dispatchEvent(new CustomEvent("open-command-palette"));
@@ -179,15 +186,15 @@ export function Topbar() {
<DropdownMenuContent align="end" className="w-56">
<div className="flex items-center gap-2 px-2 py-1.5 text-sm">
<Avatar className="h-8 w-8">
<AvatarFallback className="text-xs">U</AvatarFallback>
<AvatarFallback className="text-xs">{userInitials}</AvatarFallback>
</Avatar>
<div className="flex flex-col">
<span className="font-medium">User</span>
<span className="text-xs text-muted-foreground">user@projecte.app</span>
<span className="font-medium truncate">{userName}</span>
<span className="text-xs text-muted-foreground truncate">{userEmail}</span>
</div>
</div>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => {}}>
<DropdownMenuItem onClick={() => navigate({ to: "/settings" })}>
Profile
</DropdownMenuItem>
<DropdownMenuItem onClick={() => {
+18 -7
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef } from "react";
import { useNavigate } from "@tanstack/react-router";
import { useKeyboardShortcutsStore } from "@/lib/stores/use-keyboard-shortcuts-store";
import { useCreateDialogStore } from "@/lib/stores/use-create-dialog-store";
import { install, uninstall } from "@github/hotkey";
export function useKeyboardShortcuts() {
@@ -49,16 +50,18 @@ export function useKeyboardShortcuts() {
});
}
// n+letter new-entity sequences
const newMap: Record<string, string> = {
"n t": "/tasks",
"n h": "/habits",
"n p": "/projects",
"n n": "/notes",
// n+letter new-entity sequences — navigate AND open the create dialog on
// the target page (the page's effect consumes the store request).
const newMap: Record<string, { path: string; type: "task" | "habit" | "project" | "note" }> = {
"n t": { path: "/tasks", type: "task" },
"n h": { path: "/habits", type: "habit" },
"n p": { path: "/projects", type: "project" },
"n n": { path: "/notes", type: "note" },
};
for (const [seq, path] of Object.entries(newMap)) {
for (const [seq, { path, type }] of Object.entries(newMap)) {
addHotkey(seq, () => {
useCreateDialogStore.getState().openCreate(type);
navigate({ to: path });
});
}
@@ -84,6 +87,14 @@ export function useKeyboardShortcuts() {
return;
}
// Cmd+N / Ctrl+N — new task
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "n") {
e.preventDefault();
useCreateDialogStore.getState().openCreate("task");
navigate({ to: "/tasks" });
return;
}
// Single-key shortcuts (no modifiers)
if (e.metaKey || e.ctrlKey || e.altKey) return;
@@ -0,0 +1,23 @@
import { useEffect } from "react";
import { useCreateDialogStore } from "@/lib/stores/use-create-dialog-store";
/**
* Opens a page's local create dialog when the global "new entity" shortcut or
* command palette requests it. Pages must call this once with their entity type
* and a callback that flips their own create-open state.
*
* The store is re-read inside the effect (rather than trusting the captured
* value) so React StrictMode's double-invoked effects can't fire onOpen twice.
*/
export function useOpenCreateDialog(type: "task" | "habit" | "project" | "note" | "event", onOpen: () => void) {
const open = useCreateDialogStore((s) => s.open);
const storeType = useCreateDialogStore((s) => s.type);
useEffect(() => {
const state = useCreateDialogStore.getState();
if (state.open && state.type === type) {
useCreateDialogStore.getState().closeCreate();
onOpen();
}
}, [open, storeType, type, onOpen]);
}
+32 -4
View File
@@ -1,5 +1,6 @@
import { useEffect, useRef, useCallback } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
import type { RealtimeEvent } from "@/lib/types";
const API_BASE = "/api";
@@ -10,7 +11,11 @@ interface UseRealtimeOptions {
}
export function useRealtime(options: UseRealtimeOptions = {}) {
const { workspaceId, enabled = true } = options;
const { workspaceId: explicitWorkspace, enabled = true } = options;
// Default to the user's active domain so clients never receive (or act on)
// events from other workspaces. Callers can still pin a specific workspace.
const activeDomainId = useApiDomain();
const workspaceId = explicitWorkspace || activeDomainId || undefined;
const queryClient = useQueryClient();
const eventSourceRef = useRef<EventSource | null>(null);
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -23,13 +28,13 @@ export function useRealtime(options: UseRealtimeOptions = {}) {
switch (entityType) {
case "task":
queryKeys.push(["tasks"], ["tasks-due"], ["stats"], ["productivity-chart"]);
queryKeys.push(["tasks"], ["tasks-due"], ["stats"], ["productivity-chart"], ["analytics-daily"], ["analytics-projects"]);
break;
case "habit":
queryKeys.push(["habits"], ["habits-today"], ["streaks"]);
queryKeys.push(["habits"], ["habits-today"], ["streaks"], ["analytics-habits"]);
break;
case "project":
queryKeys.push(["projects"], ["active-projects"]);
queryKeys.push(["projects"], ["active-projects"], ["analytics-projects"]);
break;
case "note":
queryKeys.push(["notes"], ["recent-notes"]);
@@ -40,6 +45,29 @@ export function useRealtime(options: UseRealtimeOptions = {}) {
case "dashboard_widget":
queryKeys.push(["dashboard-widgets"]);
break;
case "daily_note":
queryKeys.push(["daily-notes-list"], ["daily-note"]);
break;
case "agent":
queryKeys.push(["agents"], ["agents-list"], ["agent-activity"]);
break;
case "canvas":
queryKeys.push(["canvas"]);
break;
case "webhook":
queryKeys.push(["webhooks"]);
break;
case "custom_field":
queryKeys.push(["custom-fields"]);
break;
case "section":
case "member":
queryKeys.push(["projects"]);
break;
case "comment":
case "attachment":
queryKeys.push(["tasks"], ["task"]);
break;
case "graph_edge":
queryKeys.push(["graph"]);
break;
+25
View File
@@ -0,0 +1,25 @@
import { useAuthStore } from "./stores/use-auth-store";
/**
* Boot-time session check. Runs once before the app renders so the shell never
* flashes for unauthenticated users and the logged-in identity is available
* immediately. Redirects to /login when unauthenticated and to / when an
* authenticated user lands on /login.
*/
export async function bootstrapSession(): Promise<void> {
try {
const res = await fetch("/api/auth/session", { credentials: "include" });
const data = await res.json().catch(() => ({ authenticated: false, user: null }));
const user = data?.authenticated ? data.user : null;
useAuthStore.setState({ user: user ?? null, checked: true });
const path = window.location.pathname;
if (user && path === "/login") {
window.location.replace("/");
} else if (!user && path !== "/login") {
window.location.replace("/login");
}
} catch {
useAuthStore.setState({ user: null, checked: true });
if (window.location.pathname !== "/login") window.location.replace("/login");
}
}
+21
View File
@@ -0,0 +1,21 @@
import { create } from "zustand";
export interface AuthUser {
id: string;
email: string;
name: string;
}
interface AuthState {
user: AuthUser | null;
checked: boolean;
setUser: (user: AuthUser | null) => void;
setChecked: (checked: boolean) => void;
}
export const useAuthStore = create<AuthState>()((set) => ({
user: null,
checked: false,
setUser: (user) => set({ user }),
setChecked: (checked) => set({ checked }),
}));
@@ -22,6 +22,9 @@ export const useSidebarStore = create<SidebarState>()(
}),
{
name: "project-e-sidebar",
// Never persist the transient mobile drawer state — restoring it on the
// next load would reopen the Sheet (and its dark overlay) on desktop.
partialize: (state) => ({ collapsed: state.collapsed }) as SidebarState,
}
)
);
+8 -4
View File
@@ -104,8 +104,9 @@ export interface Note {
}
export interface Backlink {
noteId: string;
noteTitle: string;
id: string;
title: string;
excerpt?: string;
}
export interface OutgoingLink {
@@ -246,9 +247,10 @@ export interface WebhookDelivery {
export interface ErrorLog {
id: string;
level: string;
source: string;
message: string;
stack: string | null;
context: Record<string, unknown> | null;
stackTrace: string | null;
metadata: Record<string, unknown> | null;
createdAt: string;
}
@@ -291,6 +293,8 @@ export interface AgentActivity {
entityType: string;
entityId: string;
metadata: Record<string, unknown> | null;
details?: Record<string, unknown> | null;
errorMessage?: string | null;
createdAt: string;
}
+24 -11
View File
@@ -1,10 +1,12 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { RouterProvider, createRouter } from "@tanstack/react-router";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { QueryClient, QueryClientProvider, MutationCache } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { toast } from "sonner";
import { routeTree } from "./routeTree";
import { ThemeProvider } from "@/components/shell/theme-provider";
import { bootstrapSession } from "./lib/session";
import "./index.css";
const queryClient = new QueryClient({
@@ -15,6 +17,15 @@ const queryClient = new QueryClient({
refetchOnWindowFocus: true,
},
},
mutationCache: new MutationCache({
// Surface failures that individual mutations don't handle themselves so
// silent validation/network errors never go unnoticed.
onError: (error, _variables, _context, mutation) => {
if (!mutation.options.onError) {
toast.error((error as Error).message || "Request failed");
}
},
}),
});
const router = createRouter({ routeTree });
@@ -28,13 +39,15 @@ declare module "@tanstack/react-router" {
const rootEl = document.getElementById("root");
if (!rootEl) throw new Error("Root element not found");
ReactDOM.createRoot(rootEl).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<RouterProvider router={router} />
</ThemeProvider>
{import.meta.env.DEV && <ReactQueryDevtools initialIsOpen={false} />}
</QueryClientProvider>
</React.StrictMode>
);
bootstrapSession().finally(() => {
ReactDOM.createRoot(rootEl).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<RouterProvider router={router} />
</ThemeProvider>
{import.meta.env.DEV && <ReactQueryDevtools initialIsOpen={false} />}
</QueryClientProvider>
</React.StrictMode>
);
});
+8 -4
View File
@@ -10,16 +10,20 @@ import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
function AppLayout() {
useKeyboardShortcuts();
// Apply persisted appearance preferences (density, reduced motion) right
// after the first paint. The settings page updates these live while open;
// this covers reloads where the settings page was never visited.
// Apply persisted appearance preferences (density, reduced motion, font size)
// right after the first paint. The settings page updates these live while
// open; this covers reloads where the settings page was never visited.
useEffect(() => {
const root = document.documentElement;
root.classList.remove("density-compact", "density-spacious");
root.classList.remove("density-compact", "density-spacious", "reduce-motion");
const density = localStorage.getItem("density");
if (density === "compact") root.classList.add("density-compact");
if (density === "spacious") root.classList.add("density-spacious");
if (localStorage.getItem("reduced-motion") === "true") root.classList.add("reduce-motion");
const fontSize = localStorage.getItem("font-size");
if (fontSize === "large") root.style.fontSize = "18px";
else if (fontSize === "small") root.style.fontSize = "13px";
else root.style.fontSize = "16px";
}, []);
return (
+6 -2
View File
@@ -1,11 +1,15 @@
import { createRoute } from "@tanstack/react-router";
import { createRoute, useNavigate } from "@tanstack/react-router";
import { Route as appRoute } from "../_app";
function AgentsPage() {
const navigate = useNavigate();
return (
<div className="flex flex-col items-center justify-center min-h-[60vh]">
<h1 className="text-3xl font-bold mb-2">Agent Activity</h1>
<p className="text-muted-foreground">Coming in T7 agent activity feed.</p>
<p className="text-muted-foreground mb-4">The agent activity feed moved to its own page.</p>
<button onClick={() => navigate({ to: "/agents/activity" })} className="text-primary underline underline-offset-4">
Open Agent Activity
</button>
</div>
);
}
+51 -22
View File
@@ -3,6 +3,7 @@ import { createRoute } from "@tanstack/react-router";
import { Route as appRoute } from "../../_app";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api, useApiQuery, useApiMutation } from "@/lib/api";
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
import { Bot, Filter, Calendar, RefreshCw, ExternalLink, Clock, Activity } from "lucide-react";
import { Button } from "@/components/ui/button";
import { LoadingState, EmptyState } from "@/components/state";
@@ -32,52 +33,76 @@ function AgentActivityPage() {
const [dateTo, setDateTo] = useState("");
const [liveActivities, setLiveActivities] = useState<AgentActivity[]>([]);
const eventSourceRef = useRef<EventSource | null>(null);
const activeDomainId = useApiDomain();
// Keep latest filters reachable from the SSE effect without reconnecting.
const filtersRef = useRef({ agentFilter, actionFilter, dateFrom, dateTo });
filtersRef.current = { agentFilter, actionFilter, dateFrom, dateTo };
// Live entries are transient and never filtered server-side, so clear them
// whenever the user changes any filter.
useEffect(() => {
setLiveActivities([]);
}, [agentFilter, actionFilter, dateFrom, dateTo]);
// Fetch agents for filter dropdown
const { data: agentsData } = useApiQuery<PaginatedResponse<Agent>>(["agents-list"], "/agents");
const { data: agentsData } = useApiQuery<PaginatedResponse<Agent>>(["agents-list", activeDomainId], "/agents" + (activeDomainId ? "?domain=" + activeDomainId : ""));
const agents = agentsData?.items || [];
// Build query params
const params = new URLSearchParams({ limit: "100" });
if (agentFilter) params.set("agentId", agentFilter);
if (actionFilter) params.set("action", actionFilter);
if (activeDomainId) params.set("domain", activeDomainId);
if (agentFilter && agentFilter !== "all") params.set("agentId", agentFilter);
if (actionFilter && actionFilter !== "all") params.set("action", actionFilter);
if (dateFrom) params.set("from", dateFrom);
if (dateTo) params.set("to", dateTo);
const { data: activityData, isLoading } = useApiQuery<{ items: AgentActivity[]; totalItems: number }>(
["agent-activity", agentFilter, actionFilter, dateFrom, dateTo],
"/agents/" + (agentFilter || "_all") + "/activity?" + params.toString()
"/agents/" + (agentFilter && agentFilter !== "all" ? agentFilter : "_all") + "/activity?" + params.toString()
);
const activities = [...liveActivities, ...(activityData?.items || [])];
// SSE for live updates
useEffect(() => {
const es = new EventSource("/api/realtime");
const params = new URLSearchParams();
if (activeDomainId) params.set("workspace_id", activeDomainId);
const es = new EventSource("/api/realtime" + (params.toString() ? "?" + params.toString() : ""));
eventSourceRef.current = es;
es.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
// Realtime events are flat: { type: entityType, action, id, workspace_id }.
// Match only agent events so unrelated task/habit/etc. activity doesn't leak in.
if (data.type === "agent") {
const entry: AgentActivity = {
id: `${data.id}-live-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
agentId: data.id,
action: data.action,
description: `Live update: ${data.action}`,
entityType: "agent",
entityId: data.id,
metadata: null,
createdAt: new Date().toISOString(),
};
setLiveActivities((prev) => [entry, ...prev].slice(0, 5));
}
// Match only agent events for the active workspace so unrelated task/habit
// etc. activity (or another workspace's events) doesn't leak in.
if (data.type !== "agent") return;
if (activeDomainId && data.workspace_id !== activeDomainId) return;
const { agentFilter, actionFilter, dateFrom, dateTo } = filtersRef.current;
if (agentFilter && agentFilter !== "all" && data.id !== agentFilter) return;
if (actionFilter && actionFilter !== "all" && data.action !== actionFilter) return;
const now = new Date();
const nowKey = now.toISOString().slice(0, 10);
if (dateFrom && nowKey < dateFrom) return;
if (dateTo && nowKey > dateTo) return;
const entry: AgentActivity = {
id: `${data.id}-live-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
agentId: data.id,
action: data.action,
description: `Live update: ${data.action}`,
entityType: "agent",
entityId: data.id,
metadata: null,
createdAt: now.toISOString(),
};
setLiveActivities((prev) => [entry, ...prev].slice(0, 5));
} catch {}
};
es.onerror = () => {};
return () => { es.close(); };
}, []);
}, [activeDomainId]);
const getActionColor = (action: string) => {
const found = ACTION_TYPES.find((a) => a.id === action);
@@ -106,7 +131,7 @@ function AgentActivityPage() {
<SelectValue placeholder="All agents" />
</SelectTrigger>
<SelectContent>
<SelectItem value=" ">All agents</SelectItem>
<SelectItem value="all">All agents</SelectItem>
{agents.map((a) => (
<SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>
))}
@@ -118,7 +143,7 @@ function AgentActivityPage() {
<SelectValue placeholder="All actions" />
</SelectTrigger>
<SelectContent>
<SelectItem value=" ">All actions</SelectItem>
<SelectItem value="all">All actions</SelectItem>
{ACTION_TYPES.map((a) => (
<SelectItem key={a.id} value={a.id}>{a.label}</SelectItem>
))}
@@ -153,6 +178,10 @@ function AgentActivityPage() {
<Badge variant="outline" className="text-[10px]">{a.entityType}</Badge>
</div>
{a.description && <p className="text-sm text-muted-foreground mt-0.5">{a.description}</p>}
{!a.description && a.details && Object.keys(a.details).length > 0 && (
<p className="text-sm text-muted-foreground mt-0.5">{JSON.stringify(a.details)}</p>
)}
{a.errorMessage && <p className="text-sm text-destructive mt-0.5">{a.errorMessage}</p>}
<div className="flex items-center gap-2 mt-1">
<Clock className="h-3 w-3 text-muted-foreground" />
<span className="text-xs text-muted-foreground">{format(parseISO(a.createdAt), "MMM d, HH:mm:ss")}</span>
+3
View File
@@ -3,6 +3,7 @@ import { createRoute } from "@tanstack/react-router";
import { Route as appRoute } from "../_app";
import { useApiQuery } from "@/lib/api";
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
import { useRealtime } from "@/hooks/use-realtime";
import { Download, Calendar } from "lucide-react";
import { Button } from "@/components/ui/button";
import { LoadingState, ErrorState } from "@/components/state";
@@ -140,6 +141,8 @@ function AnalyticsPage() {
const activeDomainId = useApiDomain();
const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : "";
useRealtime({ enabled: true });
const { data: habitData, isLoading: habitsLoading, error: habitsError, refetch: refetchHabits } = useApiQuery<HabitAnalytics>(["analytics-habits", activeDomainId, range], "/analytics/habits?range=" + range + domainSuffix);
const { data: projectData, isLoading: projectsLoading, error: projectsError, refetch: refetchProjects } = useApiQuery<ProjectAnalytics>(["analytics-projects", activeDomainId, range], "/analytics/projects?range=" + range + domainSuffix);
const { data: dailyData, isLoading: dailyLoading, error: dailyError, refetch: refetchDaily } = useApiQuery<DailyAnalytics>(["analytics-daily", activeDomainId, range], "/analytics/daily?range=" + range + domainSuffix);
+7 -3
View File
@@ -5,6 +5,7 @@ import { useQueryClient, useMutation } from "@tanstack/react-query";
import { api, useApiQuery, useApiMutation } from "@/lib/api";
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
import { useRealtime } from "@/hooks/use-realtime";
import { useOpenCreateDialog } from "@/hooks/use-open-create-dialog";
import { Plus, Trash2, ChevronLeft, ChevronRight } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@@ -95,6 +96,7 @@ function CustomToolbar({ date, onNavigate, label }: any) {
function EventForm({ event, onClose }: { event?: CalendarEvent; onClose: () => void }) {
const queryClient = useQueryClient();
const activeDomainId = useApiDomain();
const [title, setTitle] = useState(event?.title || "");
const [startTime, setStartTime] = useState(event?.startTime ? event.startTime.slice(0, 16) : "");
const [endTime, setEndTime] = useState(event?.endTime ? event.endTime.slice(0, 16) : "");
@@ -126,7 +128,7 @@ function EventForm({ event, onClose }: { event?: CalendarEvent; onClose: () => v
if (startTime) data.startTime = new Date(startTime).toISOString();
if (endTime) data.endTime = new Date(endTime).toISOString();
if (event) updateMutation.mutate(data);
else createMutation.mutate(data);
else createMutation.mutate({ ...data, ...(activeDomainId ? { domain: activeDomainId } : {}) });
};
return (
@@ -142,7 +144,7 @@ function EventForm({ event, onClose }: { event?: CalendarEvent; onClose: () => v
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="start">Start</Label>
<Input id="start" type={allDay ? "date" : "datetime-local"} value={startTime} onChange={(e) => setStartTime(e.target.value)} />
<Input id="start" type={allDay ? "date" : "datetime-local"} value={startTime} onChange={(e) => setStartTime(e.target.value)} required />
</div>
<div>
<Label htmlFor="end">End</Label>
@@ -172,6 +174,8 @@ function CalendarPage() {
useRealtime({ enabled: true });
useOpenCreateDialog("event", () => setCreateOpen(true));
useEffect(() => {
const check = () => setIsMobile(window.innerWidth < 768);
check();
@@ -183,7 +187,7 @@ function CalendarPage() {
const activeDomainId = useApiDomain();
const { data: eventsData, isLoading: eventsLoading } = useApiQuery<{ items: CalendarEvent[]; totalItems: number }>(
["calendar-events", activeDomainId, date.toISOString()],
["calendar-events", activeDomainId],
`/calendar/events?from=${new Date(0).toISOString()}&to=${new Date("2100-01-01").toISOString()}` + (activeDomainId ? "&domain=" + activeDomainId : "")
);
+31 -4
View File
@@ -15,6 +15,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
import { cn } from "@/lib/utils";
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
import type { Canvas, CanvasCard, PaginatedResponse } from "@/lib/types";
const BLOCK_TYPES = [
@@ -382,12 +383,13 @@ function CanvasList() {
const navigate = useNavigate();
const [createOpen, setCreateOpen] = useState(false);
const [newName, setNewName] = useState("");
const activeDomainId = useApiDomain();
const { data, isLoading } = useApiQuery<PaginatedResponse<Canvas>>(["canvas"], "/canvas");
const { data, isLoading } = useApiQuery<PaginatedResponse<Canvas>>(["canvas", activeDomainId], "/canvas" + (activeDomainId ? "?domain=" + activeDomainId : ""));
const canvases = data?.items || [];
const createMutation = useMutation({
mutationFn: (name: string) => api.post<Canvas>("/canvas", { name }),
mutationFn: (name: string) => api.post<Canvas>("/canvas", { name, ...(activeDomainId ? { domain: activeDomainId } : {}) }),
onSuccess: (canvas) => {
queryClient.invalidateQueries({ queryKey: ["canvas"] });
setCreateOpen(false);
@@ -429,9 +431,34 @@ function CanvasList() {
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{canvases.map((c) => (
<Card key={c.id} className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => navigate({ to: "/canvas/$id", params: { id: c.id } })}>
<Card key={c.id} className="cursor-pointer hover:shadow-md transition-shadow group" onClick={() => navigate({ to: "/canvas/$id", params: { id: c.id } })}>
<CardHeader className="p-4 pb-2">
<CardTitle className="text-sm font-semibold truncate">{c.name}</CardTitle>
<div className="flex items-start justify-between gap-2">
<CardTitle className="text-sm font-semibold truncate">{c.name}</CardTitle>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0 opacity-0 group-hover:opacity-100 text-destructive"
onClick={(e) => e.stopPropagation()}
aria-label={"Delete canvas " + c.name}
>
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Canvas</AlertDialogTitle>
<AlertDialogDescription>Are you sure you want to delete "{c.name}"? All blocks in it will be removed. This cannot be undone.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={(e) => e.stopPropagation()}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={(e) => { e.stopPropagation(); deleteMutation.mutate(c.id); }} className="bg-destructive text-destructive-foreground">Delete</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</CardHeader>
<CardContent className="p-4 pt-0">
<div className="flex items-center gap-2">
+11 -7
View File
@@ -13,6 +13,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
import type { DailyNote } from "@/lib/types";
import { format, startOfMonth, endOfMonth, eachDayOfInterval, getDay, isSameDay, isToday, addMonths, subMonths } from "date-fns";
@@ -26,7 +27,8 @@ function CalendarSidebar({ selectedDate, onSelectDate }: { selectedDate: Date; o
const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
// Check which dates have notes
const { data } = useApiQuery<{ items: DailyNote[]; totalItems: number }>(["daily-notes-list"], "/daily-notes");
const activeDomainId = useApiDomain();
const { data } = useApiQuery<{ items: DailyNote[]; totalItems: number }>(["daily-notes-list", activeDomainId], "/daily-notes" + (activeDomainId ? "?domain=" + activeDomainId : ""));
const notes = data?.items || [];
// The API stores daily notes at UTC midnight (YYYY-MM-DDT00:00:00.000Z).
// Slicing off the time portion yields the calendar date the note belongs to
@@ -88,6 +90,8 @@ function CalendarSidebar({ selectedDate, onSelectDate }: { selectedDate: Date; o
function DailyNoteEditor({ date }: { date: Date }) {
const queryClient = useQueryClient();
const activeDomainId = useApiDomain();
const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : "";
const dateStr = format(date, "yyyy-MM-dd");
const [content, setContent] = useState("");
const [mood, setMood] = useState<number | null>(null);
@@ -106,8 +110,8 @@ function DailyNoteEditor({ date }: { date: Date }) {
noteIdRef.current = noteId;
const { data: note, isLoading } = useApiQuery<DailyNote | null>(
["daily-note", dateStr],
"/daily-notes?date=" + dateStr
["daily-note", dateStr, activeDomainId],
"/daily-notes?date=" + dateStr + domainSuffix
);
useEffect(() => {
@@ -174,10 +178,10 @@ function DailyNoteEditor({ date }: { date: Date }) {
if (id) {
updateMutation.mutate({ id, data: { content: newContent, mood: newMood, energy: newEnergy } });
} else if (newContent.trim()) {
createMutation.mutate({ date: dateStr, content: newContent, mood: newMood, energy: newEnergy });
createMutation.mutate({ date: dateStr, content: newContent, mood: newMood, energy: newEnergy, ...(activeDomainId ? { domain: activeDomainId } : {}) });
}
}, 1500);
}, [dateStr, updateMutation, createMutation]);
}, [dateStr, activeDomainId, updateMutation, createMutation]);
const handleContentChange = (value: string) => {
setContent(value);
@@ -191,7 +195,7 @@ function DailyNoteEditor({ date }: { date: Date }) {
} else if (isNew && !createMutation.isPending) {
// No note exists for this day yet — create it so the mood is recorded
// even before any content is typed.
createMutation.mutate({ date: dateStr, content: content, mood: value, energy: energy });
createMutation.mutate({ date: dateStr, content: content, mood: value, energy: energy, ...(activeDomainId ? { domain: activeDomainId } : {}) });
}
};
@@ -202,7 +206,7 @@ function DailyNoteEditor({ date }: { date: Date }) {
} else if (isNew && !createMutation.isPending) {
// No note exists for this day yet — create it so the energy is recorded
// even before any content is typed.
createMutation.mutate({ date: dateStr, content: content, mood: mood, energy: value });
createMutation.mutate({ date: dateStr, content: content, mood: mood, energy: value, ...(activeDomainId ? { domain: activeDomainId } : {}) });
}
};
+39 -3
View File
@@ -5,6 +5,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api, useApiQuery, useApiMutation } from "@/lib/api";
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
import { useRealtime } from "@/hooks/use-realtime";
import { useOpenCreateDialog } from "@/hooks/use-open-create-dialog";
import { Plus, Flame, Trash2, Check, Pencil } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -23,6 +24,7 @@ import type { Habit, HabitCompletion, PaginatedResponse } from "@/lib/types";
function HabitForm({ habit, onClose }: { habit?: Habit; onClose: () => void }) {
const queryClient = useQueryClient();
const activeDomainId = useApiDomain();
const [name, setName] = useState(habit?.name || "");
const [description, setDescription] = useState(habit?.description || "");
const [frequency, setFrequency] = useState(habit?.frequency || "daily");
@@ -44,7 +46,7 @@ function HabitForm({ habit, onClose }: { habit?: Habit; onClose: () => void }) {
if (!name.trim()) return;
const data = { name: name.trim(), description: description || null, frequency, difficulty, goalPerPeriod };
if (habit) updateMutation.mutate(data);
else createMutation.mutate(data);
else createMutation.mutate({ ...data, ...(activeDomainId ? { domain: activeDomainId } : {}) });
};
return (
@@ -124,14 +126,35 @@ function HabitsPage() {
useRealtime({ enabled: true });
useOpenCreateDialog("habit", () => setCreateOpen(true));
const activeDomainId = useApiDomain();
const habitQueryUrl = () => "/habits?limit=200" + (activeDomainId ? "&domain=" + activeDomainId : "");
const { data: habitsData, isLoading, isError, refetch } = useApiQuery<PaginatedResponse<Habit>>(
["habits", activeDomainId],
"/habits?limit=200" + (activeDomainId ? "&domain=" + activeDomainId : "")
habitQueryUrl()
);
const habits = habitsData?.items || [];
const hasMoreHabits = habits.length < (habitsData?.totalItems || 0);
const [loadingMoreHabits, setLoadingMoreHabits] = useState(false);
const loadMoreHabits = async () => {
if (!hasMoreHabits || loadingMoreHabits) return;
setLoadingMoreHabits(true);
try {
const next = await api.get<PaginatedResponse<Habit>>(habitQueryUrl() + "&offset=" + habits.length);
queryClient.setQueryData<PaginatedResponse<Habit>>(["habits", activeDomainId], (old) => {
if (!old) return old;
const seen = new Set(old.items.map((h) => h.id));
return { ...old, items: [...old.items, ...next.items.filter((h) => !seen.has(h.id))] };
});
} finally {
setLoadingMoreHabits(false);
}
};
const completeMutation = useMutation({
mutationFn: (id: string) => api.post("/habits/" + id + "/complete", {}),
@@ -203,6 +226,11 @@ function HabitsPage() {
<Button
size="sm"
variant="outline"
disabled={(habit.recentCompletions || []).some((c) => {
const d = new Date(c.date);
const t = new Date();
return d.getUTCFullYear() === t.getUTCFullYear() && d.getUTCMonth() === t.getUTCMonth() && d.getUTCDate() === t.getUTCDate();
})}
onClick={(e) => { e.stopPropagation(); completeMutation.mutate(habit.id); }}
aria-label={"Mark " + habit.name + " complete"}
>
@@ -225,7 +253,15 @@ function HabitsPage() {
</div>
)}
<EntityDetailPanel open={panelOpen} onOpenChange={setPanelOpen} title={selectedHabit?.name || "Habit Details"}>
{hasMoreHabits && (
<div className="flex justify-center pt-2">
<Button variant="outline" size="sm" onClick={loadMoreHabits} disabled={loadingMoreHabits}>
{loadingMoreHabits ? "Loading..." : "Load more habits"}
</Button>
</div>
)}
<EntityDetailPanel open={panelOpen} onOpenChange={(o) => { setPanelOpen(o); if (!o) setDetailTab("overview"); }} title={selectedHabit?.name || "Habit Details"}>
{selectedHabit && (
<div className="space-y-4">
<Tabs value={detailTab} onValueChange={setDetailTab}>
+59 -29
View File
@@ -4,6 +4,7 @@ import { Route as appRoute } from "../_app";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api, useApiQuery, useApiMutation } from "@/lib/api";
import { useRealtime } from "@/hooks/use-realtime";
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
import { Plus, Settings2, Trash2, ListTodo, Flame, FileText, FolderKanban, Calendar, Zap, TrendingUp, Target } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@@ -31,7 +32,9 @@ const WIDGET_TYPES = [
] as const;
function TasksDueWidget() {
const { data } = useApiQuery<PaginatedResponse<Task>>(["tasks-due"], "/tasks?limit=10&status=todo,in_progress&sort=due_date");
const activeDomainId = useApiDomain();
const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : "";
const { data } = useApiQuery<PaginatedResponse<Task>>(["tasks-due", activeDomainId], "/tasks?limit=10&status=todo,in_progress&sort=due_date" + domainSuffix);
const tasks = data?.items || [];
const today = tasks.filter((t) => t.dueDate && isToday(parseISO(t.dueDate)));
const overdue = tasks.filter((t) => t.dueDate && isPast(parseISO(t.dueDate)) && t.status !== "done");
@@ -70,7 +73,9 @@ function TasksDueWidget() {
}
function HabitsTodayWidget() {
const { data } = useApiQuery<PaginatedResponse<Habit>>(["habits-today"], "/habits?limit=20");
const activeDomainId = useApiDomain();
const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : "";
const { data } = useApiQuery<PaginatedResponse<Habit>>(["habits-today", activeDomainId], "/habits?limit=20" + domainSuffix);
const habits = data?.items || [];
const queryClient = useQueryClient();
const completeMutation = useMutation({
@@ -87,30 +92,39 @@ function HabitsTodayWidget() {
{habits.length === 0 ? (
<p className="text-sm text-muted-foreground">No habits yet</p>
) : (
habits.slice(0, 6).map((h) => (
<div key={h.id} className="flex items-center gap-2 py-1">
<button
onClick={() => completeMutation.mutate(h.id)}
className={cn("w-4 h-4 rounded border shrink-0 flex items-center justify-center", h.streakCount > 0 ? "bg-green-500 border-green-500" : "border-muted-foreground/30 hover:border-primary")}
aria-label={"Complete " + h.name}
>
{h.streakCount > 0 && <span className="text-[10px] text-white">\u2713</span>}
</button>
<span className="text-sm truncate flex-1">{h.name}</span>
{h.streakCount > 0 && (
<Badge variant="secondary" className="text-[10px] shrink-0">
<Flame className="h-2.5 w-2.5 mr-0.5" />{h.streakCount}
</Badge>
)}
</div>
))
habits.slice(0, 6).map((h) => {
const doneToday = (h.recentCompletions || []).some((c) => {
const d = new Date(c.date);
const today = new Date();
return d.getUTCFullYear() === today.getUTCFullYear() && d.getUTCMonth() === today.getUTCMonth() && d.getUTCDate() === today.getUTCDate();
});
return (
<div key={h.id} className="flex items-center gap-2 py-1">
<button
onClick={() => completeMutation.mutate(h.id)}
className={cn("w-4 h-4 rounded border shrink-0 flex items-center justify-center", doneToday ? "bg-green-500 border-green-500" : "border-muted-foreground/30 hover:border-primary")}
aria-label={doneToday ? h.name + " (completed)" : "Complete " + h.name}
>
{doneToday && <span className="text-[10px] text-white">\u2713</span>}
</button>
<span className="text-sm truncate flex-1">{h.name}</span>
{h.streakCount > 0 && (
<Badge variant="secondary" className="text-[10px] shrink-0">
<Flame className="h-2.5 w-2.5 mr-0.5" />{h.streakCount}
</Badge>
)}
</div>
);
})
)}
</div>
);
}
function RecentNotesWidget() {
const { data } = useApiQuery<PaginatedResponse<Note>>(["recent-notes"], "/notes?limit=5&sort=-updated");
const activeDomainId = useApiDomain();
const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : "";
const { data } = useApiQuery<PaginatedResponse<Note>>(["recent-notes", activeDomainId], "/notes?limit=5&sort=-updated" + domainSuffix);
const notes = data?.items || [];
return (
<div className="space-y-2">
@@ -129,7 +143,9 @@ function RecentNotesWidget() {
}
function ActiveProjectsWidget() {
const { data } = useApiQuery<PaginatedResponse<Project>>(["active-projects"], "/projects?limit=10&status=active");
const activeDomainId = useApiDomain();
const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : "";
const { data } = useApiQuery<PaginatedResponse<Project>>(["active-projects", activeDomainId], "/projects?limit=10&status=active" + domainSuffix);
const projects = data?.items || [];
return (
<div className="space-y-3">
@@ -151,7 +167,9 @@ function ActiveProjectsWidget() {
}
function UpcomingEventsWidget() {
const { data } = useApiQuery<PaginatedResponse<CalendarEvent>>(["upcoming-events"], "/calendar/events?limit=20");
const activeDomainId = useApiDomain();
const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : "";
const { data } = useApiQuery<PaginatedResponse<CalendarEvent>>(["upcoming-events", activeDomainId], "/calendar/events?limit=20" + domainSuffix);
const events = data?.items || [];
const now = new Date();
const weekFromNow = addDays(now, 7);
@@ -177,7 +195,9 @@ function UpcomingEventsWidget() {
}
function StreakCounterWidget() {
const { data } = useApiQuery<PaginatedResponse<Habit>>(["streaks"], "/habits?limit=50");
const activeDomainId = useApiDomain();
const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : "";
const { data } = useApiQuery<PaginatedResponse<Habit>>(["streaks", activeDomainId], "/habits?limit=50" + domainSuffix);
const habits = data?.items || [];
const bestStreak = Math.max(...habits.map((h) => h.streakCount || 0), 0);
const totalActive = habits.filter((h) => h.streakCount > 0).length;
@@ -199,10 +219,11 @@ function StreakCounterWidget() {
function QuickCaptureWidget() {
const queryClient = useQueryClient();
const activeDomainId = useApiDomain();
const [text, setText] = useState("");
const [type, setType] = useState<"task" | "note">("task");
const createTask = useMutation({
mutationFn: (title: string) => api.post<Task>("/tasks", { title, status: "todo", priority: "medium" }),
mutationFn: (title: string) => api.post<Task>("/tasks", { title, status: "todo", priority: "medium", ...(activeDomainId ? { domain: activeDomainId } : {}) }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tasks-due"] });
setText("");
@@ -211,7 +232,7 @@ function QuickCaptureWidget() {
onError: (err) => toast.error(err.message || "Failed to create task"),
});
const createNote = useMutation({
mutationFn: (title: string) => api.post<Note>("/notes", { title, content: "" }),
mutationFn: (title: string) => api.post<Note>("/notes", { title, content: "", ...(activeDomainId ? { domain: activeDomainId } : {}) }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["recent-notes"] });
setText("");
@@ -244,7 +265,9 @@ function QuickCaptureWidget() {
}
function ProductivityChartWidget() {
const { data } = useApiQuery<any>(["productivity-chart"], "/analytics/productivity?range=30");
const activeDomainId = useApiDomain();
const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : "";
const { data } = useApiQuery<any>(["productivity-chart", activeDomainId], "/analytics/productivity?range=30" + domainSuffix);
const stats = data;
if (!stats) return <p className="text-sm text-muted-foreground">Loading...</p>;
return (
@@ -269,7 +292,9 @@ function ProductivityChartWidget() {
}
function StatsWidget() {
const { data } = useApiQuery<any>(["stats"], "/analytics/productivity?range=30");
const activeDomainId = useApiDomain();
const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : "";
const { data } = useApiQuery<any>(["stats", activeDomainId], "/analytics/productivity?range=30" + domainSuffix);
const stats = data;
if (!stats) return <p className="text-sm text-muted-foreground">Loading...</p>;
return (
@@ -379,7 +404,6 @@ function ConfigureWidgetDialog({ widget, open, onOpenChange, onSave }: { widget:
<SelectItem value="2">2 columns</SelectItem>
<SelectItem value="3">3 columns</SelectItem>
<SelectItem value="4">4 columns</SelectItem>
<SelectItem value="6">6 columns</SelectItem>
</SelectContent>
</Select>
</div>
@@ -459,7 +483,13 @@ function DashboardPage() {
) : widgets.length === 0 ? (
<div className="text-center py-12">
<p className="text-muted-foreground mb-4">Your dashboard is empty. Add some widgets to get started!</p>
<Button onClick={() => handleAddWidget("tasks_due")}><Plus className="h-4 w-4 mr-2" />Add Default Widgets</Button>
<Button onClick={() => {
const defaults = ["tasks_due", "habits_today", "recent_notes", "active_projects", "upcoming_events", "streak_counter", "quick_capture", "productivity_chart"];
defaults.forEach((type, i) => {
const typeInfo = WIDGET_TYPES.find((t) => t.id === type);
createMutation.mutate({ type, title: typeInfo?.label || type, layout: { x: 0, y: i, w: typeInfo?.defaultW || 2, h: typeInfo?.defaultH || 2 } });
});
}}><Plus className="h-4 w-4 mr-2" />Add Default Widgets</Button>
</div>
) : (
<div className="grid grid-cols-1 gap-4 auto-rows-[minmax(120px,auto)] sm:grid-cols-2 lg:grid-cols-4">
+57 -9
View File
@@ -5,13 +5,16 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api, useApiQuery } from "@/lib/api";
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
import { useRealtime } from "@/hooks/use-realtime";
import { useOpenCreateDialog } from "@/hooks/use-open-create-dialog";
import { Plus, Trash2, Search, Pin, FileText, Link as LinkIcon, History } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import type { Note, PaginatedResponse } from "@/lib/types";
import { format, parseISO } from "date-fns";
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Link from "@tiptap/extension-link";
@@ -135,9 +138,10 @@ const NoteTitleInput = memo(function NoteTitleInput({ noteId, initialTitle }: {
});
// Memoized right pane - only re-renders when note changes, not on parent re-renders
const NoteEditorPane = memo(function NoteEditorPane({ note, onDelete }: { note: Note; onDelete: (id: string) => void }) {
const NoteEditorPane = memo(function NoteEditorPane({ note, onDelete, onOpenNote }: { note: Note; onDelete: (id: string) => void; onOpenNote: (note: Note) => void }) {
const [showBacklinks, setShowBacklinks] = useState(false);
const [showVersions, setShowVersions] = useState(false);
const [versions, setVersions] = useState<{ id: string; createdAt: string; action?: string; changes?: Record<string, unknown> | null }[]>([]);
const queryClient = useQueryClient();
const updateMutation = useMutation({
@@ -157,7 +161,7 @@ const NoteEditorPane = memo(function NoteEditorPane({ note, onDelete }: { note:
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setShowBacklinks(!showBacklinks)} aria-label="Backlinks">
<LinkIcon className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setShowVersions(!showVersions)} aria-label="Version history">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => { setShowVersions(!showVersions); if (!showVersions) { api.get<{ items: { id: string; createdAt: string; action?: string; changes?: Record<string, unknown> | null }[] }>("/notes/" + note.id + "/versions").then((data) => setVersions(data.items || [])).catch(() => setVersions([])); } }} aria-label="Version history">
<History className="h-4 w-4" />
</Button>
<AlertDialog>
@@ -188,8 +192,12 @@ const NoteEditorPane = memo(function NoteEditorPane({ note, onDelete }: { note:
<h4 className="text-sm font-semibold mb-2">Linked from</h4>
<div className="space-y-1">
{note.backlinks.map((bl) => (
<div key={bl.noteId} className="text-sm text-muted-foreground hover:text-foreground cursor-pointer">
{bl.noteTitle}
<div
key={bl.id}
className="text-sm text-muted-foreground hover:text-foreground cursor-pointer"
onClick={() => { const linked: Note = { ...note, id: bl.id, title: bl.title }; onOpenNote(linked); }}
>
{bl.title}
</div>
))}
</div>
@@ -199,7 +207,18 @@ const NoteEditorPane = memo(function NoteEditorPane({ note, onDelete }: { note:
{showVersions && (
<div className="border-t p-3">
<h4 className="text-sm font-semibold mb-2">Version History</h4>
<p className="text-xs text-muted-foreground">Version history available via API.</p>
{versions.length === 0 ? (
<p className="text-xs text-muted-foreground">No versions yet.</p>
) : (
<div className="space-y-1">
{versions.map((v) => (
<div key={v.id} className="flex items-center justify-between text-xs text-muted-foreground">
<span>{format(parseISO(v.createdAt), "MMM d, yyyy HH:mm")}</span>
{v.action && <Badge variant="secondary" className="text-[10px] capitalize">{v.action}</Badge>}
</div>
))}
</div>
)}
</div>
)}
</>
@@ -214,17 +233,39 @@ function NotesPage() {
useRealtime({ enabled: true });
useOpenCreateDialog("note", () => createMutation.mutate());
const activeDomainId = useApiDomain();
const notesQueryUrl = () =>
"/notes?limit=200" + (activeDomainId ? "&domain=" + activeDomainId : "") + (search ? "&search=" + encodeURIComponent(search) : "");
const { data: notesData, isLoading } = useApiQuery<PaginatedResponse<Note>>(
["notes", activeDomainId, search],
"/notes?limit=200" + (activeDomainId ? "&domain=" + activeDomainId : "") + (search ? "&search=" + encodeURIComponent(search) : "")
notesQueryUrl()
);
const notes = notesData?.items || [];
const hasMoreNotes = notes.length < (notesData?.totalItems || 0);
const [loadingMoreNotes, setLoadingMoreNotes] = useState(false);
const loadMoreNotes = async () => {
if (!hasMoreNotes || loadingMoreNotes) return;
setLoadingMoreNotes(true);
try {
const next = await api.get<PaginatedResponse<Note>>(notesQueryUrl() + "&offset=" + notes.length);
queryClient.setQueryData<PaginatedResponse<Note>>(["notes", activeDomainId, search], (old) => {
if (!old) return old;
const seen = new Set(old.items.map((n) => n.id));
return { ...old, items: [...old.items, ...next.items.filter((n) => !seen.has(n.id))] };
});
} finally {
setLoadingMoreNotes(false);
}
};
const createMutation = useMutation({
mutationFn: () => api.post<Note>("/notes", { title: "Untitled", content: "" }),
mutationFn: () => api.post<Note>("/notes", { title: "Untitled", content: "", ...(activeDomainId ? { domain: activeDomainId } : {}) }),
onSuccess: (note) => {
queryClient.invalidateQueries({ queryKey: ["notes"] });
selectedNoteRef.current = note;
@@ -264,7 +305,7 @@ function NotesPage() {
<div className="p-3 border-b">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input placeholder="Search notes..." value={search} onChange={(e) => setSearch(e.target.value)} className="pl-8" tabIndex={-1} onMouseDown={(e) => e.preventDefault()} />
<Input placeholder="Search notes..." value={search} onChange={(e) => setSearch(e.target.value)} className="pl-8" aria-label="Search notes" />
</div>
</div>
<div className="p-2">
@@ -299,13 +340,20 @@ function NotesPage() {
))}
</div>
)}
{hasMoreNotes && (
<div className="p-2">
<Button variant="outline" size="sm" className="w-full" onClick={loadMoreNotes} disabled={loadingMoreNotes}>
{loadingMoreNotes ? "Loading..." : "Load more notes"}
</Button>
</div>
)}
</ScrollArea>
</div>
{/* Right pane - editor (memoized, won't re-render on parent state changes) */}
<div className="flex-1 flex flex-col min-h-64 md:min-h-0">
{selectedNote ? (
<NoteEditorPane key={selectedNote.id} note={selectedNote} onDelete={handleDeleteNote} />
<NoteEditorPane key={selectedNote.id} note={selectedNote} onDelete={handleDeleteNote} onOpenNote={selectNote} />
) : (
<div className="flex items-center justify-center flex-1 text-muted-foreground">
<div className="text-center">
+33 -2
View File
@@ -5,6 +5,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api, useApiQuery, useApiMutation } from "@/lib/api";
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
import { useRealtime } from "@/hooks/use-realtime";
import { useOpenCreateDialog } from "@/hooks/use-open-create-dialog";
import { Plus, Pencil, Trash2, FolderKanban, Users, Calendar, ListTodo, GripVertical } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -26,6 +27,7 @@ import type { Project, Section, Task, PaginatedResponse } from "@/lib/types";
function ProjectForm({ project, onClose }: { project?: Project; onClose: () => void }) {
const queryClient = useQueryClient();
const activeDomainId = useApiDomain();
const [name, setName] = useState(project?.name || "");
const [description, setDescription] = useState(project?.description || "");
const [status, setStatus] = useState(project?.status || "active");
@@ -48,7 +50,7 @@ function ProjectForm({ project, onClose }: { project?: Project; onClose: () => v
const data: any = { name: name.trim(), description: description || null, status, color };
if (targetDate) data.targetDate = new Date(targetDate).toISOString();
if (project) updateMutation.mutate(data);
else createMutation.mutate(data);
else createMutation.mutate({ ...data, ...(activeDomainId ? { domain: activeDomainId } : {}) });
};
return (
@@ -103,14 +105,35 @@ function ProjectsPage() {
useRealtime({ enabled: true });
useOpenCreateDialog("project", () => setCreateOpen(true));
const activeDomainId = useApiDomain();
const projectQueryUrl = () => "/projects?limit=200" + (activeDomainId ? "&domain=" + activeDomainId : "");
const { data: projectsData, isLoading, isError, refetch } = useApiQuery<PaginatedResponse<Project>>(
["projects", activeDomainId],
"/projects?limit=200" + (activeDomainId ? "&domain=" + activeDomainId : "")
projectQueryUrl()
);
const projects = projectsData?.items || [];
const hasMoreProjects = projects.length < (projectsData?.totalItems || 0);
const [loadingMoreProjects, setLoadingMoreProjects] = useState(false);
const loadMoreProjects = async () => {
if (!hasMoreProjects || loadingMoreProjects) return;
setLoadingMoreProjects(true);
try {
const next = await api.get<PaginatedResponse<Project>>(projectQueryUrl() + "&offset=" + projects.length);
queryClient.setQueryData<PaginatedResponse<Project>>(["projects", activeDomainId], (old) => {
if (!old) return old;
const seen = new Set(old.items.map((p) => p.id));
return { ...old, items: [...old.items, ...next.items.filter((p) => !seen.has(p.id))] };
});
} finally {
setLoadingMoreProjects(false);
}
};
const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete("/projects/" + id),
@@ -191,6 +214,14 @@ function ProjectsPage() {
</div>
)}
{hasMoreProjects && (
<div className="flex justify-center pt-2">
<Button variant="outline" size="sm" onClick={loadMoreProjects} disabled={loadingMoreProjects}>
{loadingMoreProjects ? "Loading..." : "Load more projects"}
</Button>
</div>
)}
<EntityDetailPanel open={panelOpen} onOpenChange={setPanelOpen} title={selectedProject?.name || "Project Details"}>
{selectedProject && (
<Tabs value={detailTab} onValueChange={setDetailTab}>
+6 -3
View File
@@ -10,6 +10,7 @@ import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
import type { SearchResult } from "@/lib/types";
const SEARCH_TYPES = [
@@ -54,9 +55,11 @@ function SearchPage() {
if (inputRef.current) inputRef.current.focus();
}, []);
const activeDomainId = useApiDomain();
const { data: searchData, isLoading } = useApiQuery<{ results: SearchResult[]; totalCount: number }>(
["search", debouncedQuery, ...Array.from(selectedTypes)],
"/search?q=" + encodeURIComponent(debouncedQuery) + "&types=" + Array.from(selectedTypes).join(",") + "&limit=50"
["search", activeDomainId, debouncedQuery, ...Array.from(selectedTypes)],
"/search?q=" + encodeURIComponent(debouncedQuery) + "&types=" + Array.from(selectedTypes).join(",") + "&limit=50" + (activeDomainId ? "&domain=" + activeDomainId : "")
);
const results = searchData?.results || [];
@@ -166,7 +169,7 @@ function SearchPage() {
<div
key={result.id + result.type}
className="flex items-center justify-between p-3 rounded-lg hover:bg-accent cursor-pointer transition-colors"
onClick={() => navigate({ to: result.link as any })}
onClick={() => navigate({ to: result.type === "domain" ? "/settings" : (result.link as any) })}
>
<div className="min-w-0 flex-1">
<p className="font-medium text-sm truncate">{result.title}</p>
+27 -15
View File
@@ -19,6 +19,7 @@ import { Separator } from "@/components/ui/separator";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
import { cn } from "@/lib/utils";
import { useThemeStore, ACCENT_PALETTE, type ThemeMode, type AccentColor } from "@/lib/stores/use-theme-store";
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
import type { Domain, CustomField, Webhook as WebhookType, ErrorLog, Agent, PaginatedResponse } from "@/lib/types";
const SETTINGS_TABS = [
@@ -40,15 +41,19 @@ const ACCENT_COLORS = Object.entries(ACCENT_PALETTE).map(([key, val]) => ({
const SHORTCUTS_MAP: Record<string, string> = {
"Cmd+K": "Command palette",
"Cmd+N": "New task",
"g+t": "Go to Tasks",
"g+h": "Go to Habits",
"g+p": "Go to Projects",
"g+n": "Go to Notes",
"g+c": "Go to Calendar",
"g+g": "Go to Graph",
"g+d": "Go to Dashboard",
"g+s": "Go to Settings",
"g+a": "Go to Analytics",
"n": "New task / note (context dependent)",
"n+t": "New task",
"n+h": "New habit",
"n+p": "New project",
"n+n": "New note",
"?": "Show keyboard shortcuts help",
};
@@ -204,7 +209,7 @@ function DomainsTab() {
<Button variant="ghost" size="icon" className="text-destructive"><Trash2 className="h-4 w-4" /></Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader><AlertDialogTitle>Delete Domain</AlertDialogTitle><AlertDialogDescription>Are you sure? This cannot be undone.</AlertDialogDescription></AlertDialogHeader>
<AlertDialogHeader><AlertDialogTitle>Delete Domain</AlertDialogTitle><AlertDialogDescription>Are you sure? This permanently deletes this workspace and <span className="font-semibold">all</span> of its tasks, habits, projects, notes, calendar events, and settings. This cannot be undone.</AlertDialogDescription></AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => deleteMutation.mutate(d.id)} className="bg-destructive">Delete</AlertDialogAction>
@@ -279,8 +284,9 @@ function TagsTab() {
function CustomFieldsTab() {
const queryClient = useQueryClient();
const activeDomainId = useApiDomain();
const [entityFilter, setEntityFilter] = useState("");
const { data } = useApiQuery<PaginatedResponse<CustomField>>(["custom-fields", entityFilter], "/custom-fields" + (entityFilter ? "?entity=" + entityFilter : ""));
const { data } = useApiQuery<PaginatedResponse<CustomField>>(["custom-fields", entityFilter, activeDomainId], "/custom-fields" + (activeDomainId ? "?domain=" + activeDomainId : "") + (entityFilter && entityFilter !== "all" ? "&entity=" + entityFilter : ""));
const fields = data?.items || [];
const [createOpen, setCreateOpen] = useState(false);
const [editField, setEditField] = useState<CustomField | null>(null);
@@ -303,7 +309,7 @@ function CustomFieldsTab() {
const data: any = { name: form.name, type: form.type, entityType: form.entityType, required: form.required };
if (form.type === "select" || form.type === "multi_select") data.options = form.options.split(",").map((s) => s.trim()).filter(Boolean);
if (editField) updateMutation.mutate({ id: editField.id, data });
else createMutation.mutate(data);
else createMutation.mutate({ ...data, ...(activeDomainId ? { domain: activeDomainId } : {}) });
};
return (
@@ -314,7 +320,7 @@ function CustomFieldsTab() {
<Select value={entityFilter} onValueChange={setEntityFilter}>
<SelectTrigger className="w-36"><SelectValue placeholder="All entities" /></SelectTrigger>
<SelectContent>
<SelectItem value=" ">All entities</SelectItem>
<SelectItem value="all">All entities</SelectItem>
<SelectItem value="tasks">Tasks</SelectItem>
<SelectItem value="habits">Habits</SelectItem>
<SelectItem value="projects">Projects</SelectItem>
@@ -429,7 +435,8 @@ function ShortcutsTab() {
function AgentsTab() {
const queryClient = useQueryClient();
const { data } = useApiQuery<PaginatedResponse<Agent>>(["agents"], "/agents");
const activeDomainId = useApiDomain();
const { data } = useApiQuery<PaginatedResponse<Agent>>(["agents", activeDomainId], "/agents" + (activeDomainId ? "?domain=" + activeDomainId : ""));
const agents = data?.items || [];
const [createOpen, setCreateOpen] = useState(false);
const [form, setForm] = useState({ name: "", description: "", permissionTier: "read_only" });
@@ -509,7 +516,7 @@ function AgentsTab() {
))}
</SelectContent>
</Select></div>
<Button onClick={() => createMutation.mutate(form)} disabled={!form.name.trim() || createMutation.isPending}>Create</Button>
<Button onClick={() => createMutation.mutate({ ...form, ...(activeDomainId ? { domain: activeDomainId } : {}) })} disabled={!form.name.trim() || createMutation.isPending}>Create</Button>
</div>
</DialogContent>
</Dialog>
@@ -592,7 +599,8 @@ function AgentsTab() {
function WebhooksTab() {
const queryClient = useQueryClient();
const { data } = useApiQuery<PaginatedResponse<WebhookType>>(["webhooks"], "/webhooks");
const activeDomainId = useApiDomain();
const { data } = useApiQuery<PaginatedResponse<WebhookType>>(["webhooks", activeDomainId], "/webhooks" + (activeDomainId ? "?domain=" + activeDomainId : ""));
const webhooks = data?.items || [];
const [createOpen, setCreateOpen] = useState(false);
const [form, setForm] = useState({ name: "", url: "", events: "task.created,note.created" });
@@ -607,6 +615,8 @@ function WebhooksTab() {
});
const testMutation = useMutation({
mutationFn: (id: string) => api.post("/webhooks/" + id + "/test", {}),
onSuccess: () => toast.success("Test webhook queued"),
onError: (err) => toast.error(err.message || "Failed to queue test webhook"),
});
return (
@@ -621,7 +631,7 @@ function WebhooksTab() {
<div><Label>Name</Label><Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} /></div>
<div><Label>URL</Label><Input value={form.url} onChange={(e) => setForm({ ...form, url: e.target.value })} placeholder="https://example.com/webhook" /></div>
<div><Label>Events (comma-separated)</Label><Input value={form.events} onChange={(e) => setForm({ ...form, events: e.target.value })} /></div>
<Button onClick={() => createMutation.mutate({ name: form.name, url: form.url, events: form.events.split(",").map((s) => s.trim()) })} disabled={!form.name.trim() || !form.url.trim() || createMutation.isPending}>Create</Button>
<Button onClick={() => createMutation.mutate({ name: form.name, url: form.url, events: form.events.split(",").map((s) => s.trim()), ...(activeDomainId ? { domain: activeDomainId } : {}) })} disabled={!form.name.trim() || !form.url.trim() || createMutation.isPending}>Create</Button>
</div>
</DialogContent>
</Dialog>
@@ -699,6 +709,7 @@ function downloadBlob(blob: Blob, filename: string) {
function ImportExportTab() {
const queryClient = useQueryClient();
const activeDomainId = useApiDomain();
const [importData, setImportData] = useState("");
const [importResult, setImportResult] = useState<any>(null);
const [exportFormat, setExportFormat] = useState("json");
@@ -749,7 +760,7 @@ function ImportExportTab() {
const handleExport = async () => {
try {
const data = await api.post<any>("/export", { collections: exportCollections });
const data = await api.post<any>("/export", { collections: exportCollections, ...(activeDomainId ? { domain: activeDomainId } : {}) });
if (exportFormat === "csv") {
// One CSV file per selected collection; empty collections are skipped.
@@ -837,7 +848,7 @@ function ImportExportTab() {
function ErrorLogTab() {
const [level, setLevel] = useState("");
const { data } = useApiQuery<PaginatedResponse<ErrorLog>>(["error-log", level], "/error-log" + (level ? "?level=" + level : ""));
const { data } = useApiQuery<PaginatedResponse<ErrorLog>>(["error-log", level], "/error-log" + (level && level !== "all" ? "?level=" + level : ""));
const errors = data?.items || [];
const queryClient = useQueryClient();
const [expanded, setExpanded] = useState<string | null>(null);
@@ -855,7 +866,7 @@ function ErrorLogTab() {
<Select value={level} onValueChange={setLevel}>
<SelectTrigger className="w-32"><SelectValue placeholder="All levels" /></SelectTrigger>
<SelectContent>
<SelectItem value=" ">All levels</SelectItem>
<SelectItem value="all">All levels</SelectItem>
<SelectItem value="error">Error</SelectItem>
<SelectItem value="warn">Warning</SelectItem>
<SelectItem value="info">Info</SelectItem>
@@ -882,8 +893,9 @@ function ErrorLogTab() {
{expanded === e.id && (
<div className="px-3 pb-3 space-y-2">
<Separator />
{e.stack && <pre className="text-xs text-muted-foreground bg-muted/50 p-2 rounded overflow-auto max-h-40">{e.stack}</pre>}
{e.context && <pre className="text-xs text-muted-foreground bg-muted/50 p-2 rounded overflow-auto max-h-40">{JSON.stringify(e.context, null, 2)}</pre>}
{e.source && <pre className="text-xs text-muted-foreground bg-muted/50 p-2 rounded overflow-auto max-h-40">{e.source}</pre>}
{e.stackTrace && <pre className="text-xs text-muted-foreground bg-muted/50 p-2 rounded overflow-auto max-h-40">{e.stackTrace}</pre>}
{e.metadata && <pre className="text-xs text-muted-foreground bg-muted/50 p-2 rounded overflow-auto max-h-40">{JSON.stringify(e.metadata, null, 2)}</pre>}
</div>
)}
</div>
+42 -3
View File
@@ -5,6 +5,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api, useApiQuery, useApiMutation } from "@/lib/api";
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
import { useRealtime } from "@/hooks/use-realtime";
import { useOpenCreateDialog } from "@/hooks/use-open-create-dialog";
import { DndContext, DragOverlay, closestCorners, KeyboardSensor, PointerSensor, useSensor, useSensors, useDroppable, type DragEndEvent, type DragStartEvent } from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy, useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
@@ -100,6 +101,7 @@ function ColumnDroppable({ id, className, children }: { id: string; className?:
function TaskForm({ task, onClose }: { task?: Task; onClose: () => void }) {
const queryClient = useQueryClient();
const activeDomainId = useApiDomain();
const [title, setTitle] = useState(task?.title || "");
const [description, setDescription] = useState(task?.description || "");
const [status, setStatus] = useState(task?.status || "todo");
@@ -133,7 +135,7 @@ function TaskForm({ task, onClose }: { task?: Task; onClose: () => void }) {
if (task) {
updateMutation.mutate(data);
} else {
createMutation.mutate(data);
createMutation.mutate({ ...data, ...(activeDomainId ? { domain: activeDomainId } : {}) });
}
};
@@ -201,14 +203,43 @@ function TasksPage() {
useRealtime({ enabled: true });
useOpenCreateDialog("task", () => setCreateOpen(true));
const activeDomainId = useApiDomain();
const taskQueryParams = () =>
new URLSearchParams({
limit: "200",
...(activeDomainId ? { domain: activeDomainId } : {}),
...(search ? { search } : {}),
...(statusFilter && statusFilter !== "all" ? { status: statusFilter } : {}),
}).toString();
const { data: tasksData, isLoading, isError, refetch } = useApiQuery<PaginatedResponse<Task>>(
["tasks", activeDomainId, search, statusFilter],
"/tasks?" + new URLSearchParams({ limit: "200", ...(activeDomainId ? { domain: activeDomainId } : {}), ...(search ? { search } : {}), ...(statusFilter ? { status: statusFilter } : {}) }).toString()
"/tasks?" + taskQueryParams()
);
const tasks = tasksData?.items || [];
const hasMoreTasks = tasks.length < (tasksData?.totalItems || 0);
const [loadingMoreTasks, setLoadingMoreTasks] = useState(false);
const loadMoreTasks = async () => {
if (!hasMoreTasks || loadingMoreTasks) return;
setLoadingMoreTasks(true);
try {
const next = await api.get<PaginatedResponse<Task>>(
"/tasks?" + new URLSearchParams({ ...Object.fromEntries(new URLSearchParams(taskQueryParams())), offset: String(tasks.length) }).toString()
);
queryClient.setQueryData<PaginatedResponse<Task>>(["tasks", activeDomainId, search, statusFilter], (old) => {
if (!old) return old;
const seen = new Set(old.items.map((t) => t.id));
return { ...old, items: [...old.items, ...next.items.filter((t) => !seen.has(t.id))] };
});
} finally {
setLoadingMoreTasks(false);
}
};
const statusMutation = useMutation({
mutationFn: ({ id, status }: { id: string; status: string }) =>
@@ -375,7 +406,7 @@ function TasksPage() {
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-36"><SelectValue placeholder="All statuses" /></SelectTrigger>
<SelectContent>
<SelectItem value=" ">All statuses</SelectItem>
<SelectItem value="all">All statuses</SelectItem>
{STATUS_COLUMNS.map((c) => (
<SelectItem key={c.id} value={c.id}>{c.label}</SelectItem>
))}
@@ -466,6 +497,14 @@ function TasksPage() {
</div>
)}
{hasMoreTasks && (
<div className="flex justify-center pt-2">
<Button variant="outline" size="sm" onClick={loadMoreTasks} disabled={loadingMoreTasks}>
{loadingMoreTasks ? "Loading..." : "Load more tasks"}
</Button>
</div>
)}
<EntityDetailPanel open={panelOpen} onOpenChange={setPanelOpen} title={selectedTask?.title || "Task Details"}>
{selectedTask && (
<div className="space-y-4">
+3
View File
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Loader2, Sparkles } from "lucide-react";
import { useAuthStore } from "@/lib/stores/use-auth-store";
function LoginPage() {
const navigate = useNavigate();
@@ -30,6 +31,8 @@ function LoginPage() {
setError(data.error?.message || data.message || "Login failed");
return;
}
const data = await res.json().catch(() => ({}));
if (data?.user) useAuthStore.getState().setUser(data.user);
navigate({ to: "/" });
} catch {
setError("Network error");