-
-
{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 (