diff --git a/apps/web/app/(dashboard)/agents/page.tsx b/apps/web/app/(dashboard)/agents/page.tsx index c7842059..d8f919bf 100644 --- a/apps/web/app/(dashboard)/agents/page.tsx +++ b/apps/web/app/(dashboard)/agents/page.tsx @@ -48,6 +48,17 @@ import { ResizablePanel, ResizableHandle, } from "@/components/ui/resizable"; +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, +} from "@/components/ui/dropdown-menu"; +import { + Popover, + PopoverTrigger, + PopoverContent, +} from "@/components/ui/popover"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -174,13 +185,10 @@ function CreateAgentDialog({
-
-
- - - {runtimeOpen && ( - <> -
setRuntimeOpen(false)} /> -
- {runtimes.map((device) => ( - - ))} -
- - )} -
+ +
{device.device_info}
+ + + + ))} + + @@ -997,19 +999,18 @@ function AgentDetail({ const st = statusConfig[agent.status]; const runtimeDevice = getRuntimeDevice(agent, runtimes); const [activeTab, setActiveTab] = useState("skills"); - const [showMenu, setShowMenu] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false); return (
{/* Header */} -
-
+
+
{getInitials(agent.name)}
-
-

{agent.name}

+
+

{agent.name}

{st.label} @@ -1023,36 +1024,25 @@ function AgentDetail({ {runtimeDevice?.name ?? (agent.runtime_mode === "cloud" ? "Cloud" : "Local")}
- {agent.description && ( -

{agent.description}

- )}
-
- - {showMenu && ( - <> -
setShowMenu(false)} /> -
- -
- - )} -
+ + + setConfirmDelete(true)} + > + + Delete Agent + + +
{/* Tabs */} @@ -1252,29 +1242,27 @@ export default function AgentsPage() { {/* Right column — agent detail */} -
- {selected ? ( - - ) : ( -
- -

Select an agent to view details

- -
- )} -
+ {selected ? ( + + ) : ( +
+ +

Select an agent to view details

+ +
+ )}
{showCreate && ( diff --git a/apps/web/app/(dashboard)/issues/[id]/page.test.tsx b/apps/web/app/(dashboard)/issues/[id]/page.test.tsx index 7d335a8e..36a2dba3 100644 --- a/apps/web/app/(dashboard)/issues/[id]/page.test.tsx +++ b/apps/web/app/(dashboard)/issues/[id]/page.test.tsx @@ -195,8 +195,8 @@ describe("IssueDetailPage", () => { await waitFor(() => { expect( - screen.getByText("Implement authentication"), - ).toBeInTheDocument(); + screen.getAllByText("Implement authentication").length, + ).toBeGreaterThanOrEqual(1); }); expect( @@ -302,10 +302,10 @@ describe("IssueDetailPage", () => { await renderPage(); await waitFor(() => { - expect(screen.getByText("Issues")).toBeInTheDocument(); + expect(screen.getByText("Test WS")).toBeInTheDocument(); }); - const issuesLink = screen.getByText("Issues"); - expect(issuesLink.closest("a")).toHaveAttribute("href", "/issues"); + const wsLink = screen.getByText("Test WS"); + expect(wsLink.closest("a")).toHaveAttribute("href", "/issues"); }); }); diff --git a/apps/web/app/(dashboard)/issues/page.test.tsx b/apps/web/app/(dashboard)/issues/page.test.tsx index 6866d60f..ad28a800 100644 --- a/apps/web/app/(dashboard)/issues/page.test.tsx +++ b/apps/web/app/(dashboard)/issues/page.test.tsx @@ -105,10 +105,20 @@ const mockViewState = { viewMode: "board" as const, statusFilters: [] as string[], priorityFilters: [] as string[], + sortBy: "position" as const, + sortDirection: "asc" as const, + cardProperties: { priority: true, description: true, assignee: true, dueDate: true }, + listCollapsedStatuses: [] as string[], setViewMode: vi.fn(), toggleStatusFilter: vi.fn(), togglePriorityFilter: vi.fn(), + hideStatus: vi.fn(), + showStatus: vi.fn(), clearFilters: vi.fn(), + setSortBy: vi.fn(), + setSortDirection: vi.fn(), + toggleCardProperty: vi.fn(), + toggleListCollapsed: vi.fn(), }; vi.mock("@/features/issues/stores/view-store", () => ({ @@ -116,6 +126,19 @@ vi.mock("@/features/issues/stores/view-store", () => ({ (selector?: any) => (selector ? selector(mockViewState) : mockViewState), { getState: () => mockViewState, setState: vi.fn() }, ), + SORT_OPTIONS: [ + { value: "position", label: "Manual" }, + { value: "priority", label: "Priority" }, + { value: "due_date", label: "Due date" }, + { value: "created_at", label: "Created date" }, + { value: "title", label: "Title" }, + ], + CARD_PROPERTY_OPTIONS: [ + { key: "priority", label: "Priority" }, + { key: "description", label: "Description" }, + { key: "assignee", label: "Assignee" }, + { key: "dueDate", label: "Due date" }, + ], })); // Mock issue config @@ -162,6 +185,8 @@ vi.mock("@dnd-kit/core", () => ({ })); vi.mock("@dnd-kit/sortable", () => ({ + SortableContext: ({ children }: any) => children, + verticalListSortingStrategy: {}, useSortable: () => ({ attributes: {}, listeners: {}, @@ -306,8 +331,8 @@ describe("IssuesPage", () => { render(); - expect(screen.getByText("Status: All")).toBeInTheDocument(); - expect(screen.getByText("Priority: All")).toBeInTheDocument(); + expect(screen.getByText("Filter")).toBeInTheDocument(); + expect(screen.getByText("Display")).toBeInTheDocument(); }); it("shows empty board view when no issues exist", () => { diff --git a/apps/web/app/(dashboard)/settings/_components/general-tab.tsx b/apps/web/app/(dashboard)/settings/_components/general-tab.tsx index 58eb9187..5efe4a19 100644 --- a/apps/web/app/(dashboard)/settings/_components/general-tab.tsx +++ b/apps/web/app/(dashboard)/settings/_components/general-tab.tsx @@ -1,13 +1,88 @@ "use client"; import { useTheme } from "next-themes"; -import { Sun, Moon, Monitor } from "lucide-react"; +import { cn } from "@/lib/utils"; + +const LIGHT_COLORS = { + titleBar: "#e8e8e8", + content: "#ffffff", + sidebar: "#f4f4f5", + bar: "#e4e4e7", + barMuted: "#d4d4d8", +}; + +const DARK_COLORS = { + titleBar: "#333338", + content: "#27272a", + sidebar: "#1e1e21", + bar: "#3f3f46", + barMuted: "#52525b", +}; + +function WindowMockup({ + variant, + className, +}: { + variant: "light" | "dark"; + className?: string; +}) { + const colors = variant === "light" ? LIGHT_COLORS : DARK_COLORS; + + return ( +
+ {/* Title bar */} +
+ + + +
+ {/* Content area */} +
+ {/* Sidebar */} +
+
+
+
+ {/* Main */} +
+
+
+
+
+
+
+ ); +} const themeOptions = [ - { value: "light", label: "Light", icon: Sun }, - { value: "dark", label: "Dark", icon: Moon }, - { value: "system", label: "System", icon: Monitor }, -] as const; + { value: "light" as const, label: "Light" }, + { value: "dark" as const, label: "Dark" }, + { value: "system" as const, label: "System" }, +]; export function AppearanceTab() { const { theme, setTheme } = useTheme(); @@ -16,21 +91,51 @@ export function AppearanceTab() {

Theme

-
+
{themeOptions.map((opt) => { const active = theme === opt.value; return ( ); })} diff --git a/apps/web/app/(dashboard)/settings/_components/members-tab.tsx b/apps/web/app/(dashboard)/settings/_components/members-tab.tsx index 5bd83f2b..fd9f23c6 100644 --- a/apps/web/app/(dashboard)/settings/_components/members-tab.tsx +++ b/apps/web/app/(dashboard)/settings/_components/members-tab.tsx @@ -256,7 +256,7 @@ export function MembersTab() { Cancel { await confirmAction?.onConfirm(); setConfirmAction(null); diff --git a/apps/web/app/(dashboard)/settings/_components/workspace-tab.tsx b/apps/web/app/(dashboard)/settings/_components/workspace-tab.tsx index 0f4fd06d..3074d270 100644 --- a/apps/web/app/(dashboard)/settings/_components/workspace-tab.tsx +++ b/apps/web/app/(dashboard)/settings/_components/workspace-tab.tsx @@ -311,7 +311,7 @@ export function WorkspaceTab() { Cancel { await confirmAction?.onConfirm(); setConfirmAction(null); diff --git a/apps/web/features/issues/components/board-column.tsx b/apps/web/features/issues/components/board-column.tsx index 15d91b64..ee0b2901 100644 --- a/apps/web/features/issues/components/board-column.tsx +++ b/apps/web/features/issues/components/board-column.tsx @@ -13,39 +13,13 @@ import { DropdownMenuContent, DropdownMenuItem, } from "@/components/ui/dropdown-menu"; -import { STATUS_CONFIG, PRIORITY_ORDER } from "@/features/issues/config"; +import { STATUS_CONFIG } from "@/features/issues/config"; import { useModalStore } from "@/features/modals"; -import { useIssueViewStore, type SortField, type SortDirection } from "@/features/issues/stores/view-store"; +import { useIssueViewStore } from "@/features/issues/stores/view-store"; +import { sortIssues } from "@/features/issues/utils/sort"; import { StatusIcon } from "./status-icon"; import { DraggableBoardCard } from "./board-card"; -const PRIORITY_RANK: Record = Object.fromEntries( - PRIORITY_ORDER.map((p, i) => [p, i]) -); - -function sortIssues(issues: Issue[], field: SortField, direction: SortDirection): Issue[] { - const sorted = [...issues].sort((a, b) => { - switch (field) { - case "priority": - return (PRIORITY_RANK[a.priority] ?? 99) - (PRIORITY_RANK[b.priority] ?? 99); - case "due_date": { - if (!a.due_date && !b.due_date) return 0; - if (!a.due_date) return 1; - if (!b.due_date) return -1; - return new Date(a.due_date).getTime() - new Date(b.due_date).getTime(); - } - case "created_at": - return new Date(a.created_at).getTime() - new Date(b.created_at).getTime(); - case "title": - return a.title.localeCompare(b.title); - case "position": - default: - return a.position - b.position; - } - }); - return direction === "desc" ? sorted.reverse() : sorted; -} - export function BoardColumn({ status, issues, diff --git a/apps/web/features/issues/components/issues-header.tsx b/apps/web/features/issues/components/issues-header.tsx index c7fe7cb8..a6030de1 100644 --- a/apps/web/features/issues/components/issues-header.tsx +++ b/apps/web/features/issues/components/issues-header.tsx @@ -1,5 +1,6 @@ "use client"; +import { useMemo } from "react"; import { ArrowDown, ArrowUp, @@ -11,6 +12,7 @@ import { SlidersHorizontal, } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { useIssueStore } from "@/features/issues/store"; import { DropdownMenu, DropdownMenuTrigger, @@ -56,6 +58,21 @@ export function IssuesHeader() { const toggleCardProperty = useIssueViewStore((s) => s.toggleCardProperty); const clearFilters = useIssueViewStore((s) => s.clearFilters); + const allIssues = useIssueStore((s) => s.issues); + + const filteredCount = useMemo(() => { + return allIssues.filter((i) => { + if (statusFilters.length > 0 && !statusFilters.includes(i.status)) + return false; + if ( + priorityFilters.length > 0 && + !priorityFilters.includes(i.priority) + ) + return false; + return true; + }).length; + }, [allIssues, statusFilters, priorityFilters]); + const sortLabel = SORT_OPTIONS.find((o) => o.value === sortBy)?.label ?? "Manual"; const hasActiveFilters = @@ -191,12 +208,14 @@ export function IssuesHeader() { {/* Reset */} {hasActiveFilters && (
- +
)} @@ -285,7 +304,10 @@ export function IssuesHeader() {
-
+
+ + {filteredCount} {filteredCount === 1 ? "Issue" : "Issues"} + {/* New issue */}
diff --git a/apps/web/features/issues/components/list-row.tsx b/apps/web/features/issues/components/list-row.tsx index 2ffb11c4..7f2cfe19 100644 --- a/apps/web/features/issues/components/list-row.tsx +++ b/apps/web/features/issues/components/list-row.tsx @@ -3,7 +3,6 @@ import Link from "next/link"; import type { Issue } from "@/shared/types"; import { ActorAvatar } from "@/components/common/actor-avatar"; -import { StatusIcon } from "./status-icon"; import { PriorityIcon } from "./priority-icon"; function formatDate(date: string): string { @@ -23,7 +22,6 @@ export function ListRow({ issue }: { issue: Issue }) { {issue.id.slice(0, 8)} - {issue.title} {issue.due_date && ( diff --git a/apps/web/features/issues/components/list-view.tsx b/apps/web/features/issues/components/list-view.tsx index 2a583472..217ceee1 100644 --- a/apps/web/features/issues/components/list-view.tsx +++ b/apps/web/features/issues/components/list-view.tsx @@ -1,34 +1,118 @@ "use client"; -import type { Issue } from "@/shared/types"; -import { STATUS_ORDER, STATUS_CONFIG } from "@/features/issues/config"; +import { useMemo } from "react"; +import { ChevronRight, Plus } from "lucide-react"; +import { Accordion } from "@base-ui/react/accordion"; +import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; +import { Button } from "@/components/ui/button"; +import type { Issue, IssueStatus } from "@/shared/types"; +import { STATUS_CONFIG } from "@/features/issues/config"; +import { useModalStore } from "@/features/modals"; +import { useIssueViewStore } from "@/features/issues/stores/view-store"; +import { sortIssues } from "@/features/issues/utils/sort"; import { StatusIcon } from "./status-icon"; import { ListRow } from "./list-row"; -export function ListView({ issues }: { issues: Issue[] }) { - const groupOrder = STATUS_ORDER.filter((s) => s !== "cancelled"); +export function ListView({ + issues, + visibleStatuses, +}: { + issues: Issue[]; + visibleStatuses: IssueStatus[]; +}) { + const sortBy = useIssueViewStore((s) => s.sortBy); + const sortDirection = useIssueViewStore((s) => s.sortDirection); + const listCollapsedStatuses = useIssueViewStore( + (s) => s.listCollapsedStatuses + ); + const toggleListCollapsed = useIssueViewStore( + (s) => s.toggleListCollapsed + ); + + const issuesByStatus = useMemo(() => { + const map = new Map(); + for (const status of visibleStatuses) { + const filtered = issues.filter((i) => i.status === status); + map.set(status, sortIssues(filtered, sortBy, sortDirection)); + } + return map; + }, [issues, visibleStatuses, sortBy, sortDirection]); + + const expandedStatuses = useMemo( + () => + visibleStatuses.filter( + (s) => !listCollapsedStatuses.includes(s) + ), + [visibleStatuses, listCollapsedStatuses] + ); return ( -
- {groupOrder.map((status) => { - const cfg = STATUS_CONFIG[status]; - const filtered = issues.filter((i) => i.status === status); - if (filtered.length === 0) return null; - return ( -
-
- - {cfg.label} - - {filtered.length} - -
- {filtered.map((issue) => ( - - ))} -
- ); - })} +
+ { + for (const status of visibleStatuses) { + const wasExpanded = expandedStatuses.includes(status); + const isExpanded = value.includes(status); + if (wasExpanded !== isExpanded) { + toggleListCollapsed(status as IssueStatus); + } + } + }} + > + {visibleStatuses.map((status) => { + const cfg = STATUS_CONFIG[status]; + const statusIssues = issuesByStatus.get(status) ?? []; + return ( + + + + + + {cfg.label} + + {statusIssues.length} + + +
+ + + useModalStore + .getState() + .open("create-issue", { status }) + } + /> + } + > + + + Add issue + +
+
+ + {statusIssues.length > 0 ? ( + statusIssues.map((issue) => ( + + )) + ) : ( +

+ No issues +

+ )} +
+
+ ); + })} +
); } diff --git a/apps/web/features/issues/stores/view-store.ts b/apps/web/features/issues/stores/view-store.ts index 7a914249..dc4249b4 100644 --- a/apps/web/features/issues/stores/view-store.ts +++ b/apps/web/features/issues/stores/view-store.ts @@ -38,6 +38,7 @@ interface IssueViewState { sortBy: SortField; sortDirection: SortDirection; cardProperties: CardProperties; + listCollapsedStatuses: IssueStatus[]; setViewMode: (mode: ViewMode) => void; toggleStatusFilter: (status: IssueStatus) => void; togglePriorityFilter: (priority: IssuePriority) => void; @@ -47,6 +48,7 @@ interface IssueViewState { setSortBy: (field: SortField) => void; setSortDirection: (dir: SortDirection) => void; toggleCardProperty: (key: keyof CardProperties) => void; + toggleListCollapsed: (status: IssueStatus) => void; } export const useIssueViewStore = create()( @@ -63,6 +65,7 @@ export const useIssueViewStore = create()( assignee: true, dueDate: true, }, + listCollapsedStatuses: [], setViewMode: (mode) => set({ viewMode: mode }), toggleStatusFilter: (status) => @@ -107,6 +110,12 @@ export const useIssueViewStore = create()( [key]: !state.cardProperties[key], }, })), + toggleListCollapsed: (status) => + set((state) => ({ + listCollapsedStatuses: state.listCollapsedStatuses.includes(status) + ? state.listCollapsedStatuses.filter((s) => s !== status) + : [...state.listCollapsedStatuses, status], + })), }), { name: "multica_issues_view", @@ -117,6 +126,7 @@ export const useIssueViewStore = create()( sortBy: state.sortBy, sortDirection: state.sortDirection, cardProperties: state.cardProperties, + listCollapsedStatuses: state.listCollapsedStatuses, }), } ) diff --git a/apps/web/features/issues/utils/sort.ts b/apps/web/features/issues/utils/sort.ts new file mode 100644 index 00000000..35aa7ba2 --- /dev/null +++ b/apps/web/features/issues/utils/sort.ts @@ -0,0 +1,41 @@ +import type { Issue } from "@/shared/types"; +import { PRIORITY_ORDER } from "@/features/issues/config"; +import type { SortField, SortDirection } from "@/features/issues/stores/view-store"; + +const PRIORITY_RANK: Record = Object.fromEntries( + PRIORITY_ORDER.map((p, i) => [p, i]) +); + +export function sortIssues( + issues: Issue[], + field: SortField, + direction: SortDirection +): Issue[] { + const sorted = [...issues].sort((a, b) => { + switch (field) { + case "priority": + return ( + (PRIORITY_RANK[a.priority] ?? 99) - + (PRIORITY_RANK[b.priority] ?? 99) + ); + case "due_date": { + if (!a.due_date && !b.due_date) return 0; + if (!a.due_date) return 1; + if (!b.due_date) return -1; + return ( + new Date(a.due_date).getTime() - new Date(b.due_date).getTime() + ); + } + case "created_at": + return ( + new Date(a.created_at).getTime() - new Date(b.created_at).getTime() + ); + case "title": + return a.title.localeCompare(b.title); + case "position": + default: + return a.position - b.position; + } + }); + return direction === "desc" ? sorted.reverse() : sorted; +} diff --git a/apps/web/features/runtimes/components/runtimes-page.tsx b/apps/web/features/runtimes/components/runtimes-page.tsx index 2ad5ee01..f3dae8c0 100644 --- a/apps/web/features/runtimes/components/runtimes-page.tsx +++ b/apps/web/features/runtimes/components/runtimes-page.tsx @@ -13,8 +13,15 @@ import { XCircle, Zap, } from "lucide-react"; +import { useDefaultLayout } from "react-resizable-panels"; import type { AgentRuntime, RuntimeUsage, RuntimePingStatus } from "@/shared/types"; +import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { + ResizablePanelGroup, + ResizablePanel, + ResizableHandle, +} from "@/components/ui/resizable"; import { api } from "@/shared/api"; import { useAuthStore } from "@/features/auth"; import { useWorkspaceStore } from "@/features/workspace"; @@ -83,12 +90,13 @@ function RuntimeModeIcon({ mode }: { mode: string }) { function StatusBadge({ status }: { status: string }) { const isOnline = status === "online"; return ( - {isOnline ? ( @@ -96,7 +104,7 @@ function StatusBadge({ status }: { status: string }) { )} {isOnline ? "Online" : "Offline"} - + ); } @@ -394,12 +402,12 @@ function PingSection({ runtimeId }: { runtimeId: string }) { function RuntimeDetail({ runtime }: { runtime: AgentRuntime }) { return ( -
+
{/* Header */} -
-
+
+
@@ -407,10 +415,6 @@ function RuntimeDetail({ runtime }: { runtime: AgentRuntime }) {

{runtime.name}

-

- {runtime.provider} ·{" "} - {runtime.device_info || "Unknown device"} -

@@ -512,6 +516,9 @@ export default function RuntimesPage() { const [runtimes, setRuntimes] = useState([]); const [selectedId, setSelectedId] = useState(""); const [fetching, setFetching] = useState(true); + const { defaultLayout, onLayoutChanged } = useDefaultLayout({ + id: "multica_runtimes_layout", + }); const fetchRuntimes = useCallback(async () => { if (!workspace) return; @@ -553,46 +560,55 @@ export default function RuntimesPage() { } return ( -
- {/* Left column - runtime list */} -
-
-

Runtimes

- - {runtimes.filter((r) => r.status === "online").length}/ - {runtimes.length} online - + + + {/* Left column — runtime list */} +
+
+

Runtimes

+ + {runtimes.filter((r) => r.status === "online").length}/ + {runtimes.length} online + +
+ {runtimes.length === 0 ? ( +
+ +

+ No runtimes registered +

+

+ Run{" "} + + multica daemon start + {" "} + to register a local runtime. +

+
+ ) : ( +
+ {runtimes.map((runtime) => ( + setSelectedId(runtime.id)} + /> + ))} +
+ )}
- {runtimes.length === 0 ? ( -
- -

- No runtimes registered -

-

- Run{" "} - - multica daemon start - {" "} - to register a local runtime. -

-
- ) : ( -
- {runtimes.map((runtime) => ( - setSelectedId(runtime.id)} - /> - ))} -
- )} -
+ - {/* Right column - runtime detail */} -
+ + + + {/* Right column — runtime detail */} {selected ? ( ) : ( @@ -601,7 +617,7 @@ export default function RuntimesPage() {

Select a runtime to view details

)} -
-
+ + ); } diff --git a/apps/web/features/skills/components/skills-page.tsx b/apps/web/features/skills/components/skills-page.tsx index e88b81c0..92f49b83 100644 --- a/apps/web/features/skills/components/skills-page.tsx +++ b/apps/web/features/skills/components/skills-page.tsx @@ -25,6 +25,7 @@ import { ResizableHandle, } from "@/components/ui/resizable"; import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; +import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -89,7 +90,7 @@ function CreateSkillDialog({ return ( { if (!v) onClose(); }}> - + Add Skill @@ -109,7 +110,7 @@ function CreateSkillDialog({ - +
- +
{(skill.files?.length ?? 0) > 0 && ( - + {skill.files.length} file{skill.files.length !== 1 ? "s" : ""} - + )} );