mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-18 04:09:13 +02:00
Compare commits
7 Commits
agent/lamb
...
agent/lamb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
456c9b6183 | ||
|
|
f508190065 | ||
|
|
d5611d550a | ||
|
|
28b29ec5ee | ||
|
|
b98c2a5a0f | ||
|
|
b9118ae9b8 | ||
|
|
06880d6ba2 |
@@ -111,6 +111,22 @@ function createWindow(): void {
|
||||
return { action: "deny" };
|
||||
});
|
||||
|
||||
// Prevent Cmd+R / Ctrl+R / Shift+Cmd+R / Shift+Ctrl+R / F5 from
|
||||
// reloading the page. In a desktop app an accidental reload destroys
|
||||
// in-memory state (tabs, drafts, WS connections) with no URL bar to
|
||||
// navigate back. DevTools refresh (via the DevTools UI) still works.
|
||||
mainWindow.webContents.on("before-input-event", (_event, input) => {
|
||||
if (input.type !== "keyDown") return;
|
||||
const cmdOrCtrl =
|
||||
process.platform === "darwin" ? input.meta : input.control;
|
||||
if (
|
||||
(cmdOrCtrl && input.key.toLowerCase() === "r") ||
|
||||
input.key === "F5"
|
||||
) {
|
||||
_event.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
installContextMenu(mainWindow.webContents);
|
||||
|
||||
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {
|
||||
|
||||
41
packages/core/feedback/draft-store.ts
Normal file
41
packages/core/feedback/draft-store.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "../platform/workspace-storage";
|
||||
import { defaultStorage } from "../platform/storage";
|
||||
|
||||
interface FeedbackDraft {
|
||||
message: string;
|
||||
}
|
||||
|
||||
const EMPTY_DRAFT: FeedbackDraft = {
|
||||
message: "",
|
||||
};
|
||||
|
||||
interface FeedbackDraftStore {
|
||||
draft: FeedbackDraft;
|
||||
setDraft: (patch: Partial<FeedbackDraft>) => void;
|
||||
clearDraft: () => void;
|
||||
hasDraft: () => boolean;
|
||||
}
|
||||
|
||||
export const useFeedbackDraftStore = create<FeedbackDraftStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
draft: { ...EMPTY_DRAFT },
|
||||
setDraft: (patch) =>
|
||||
set((s) => ({ draft: { ...s.draft, ...patch } })),
|
||||
clearDraft: () =>
|
||||
set({ draft: { ...EMPTY_DRAFT } }),
|
||||
hasDraft: () => {
|
||||
const { draft } = get();
|
||||
return !!draft.message;
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "multica_feedback_draft",
|
||||
storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
registerForWorkspaceRehydration(() => useFeedbackDraftStore.persist.rehydrate());
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./mutations";
|
||||
export { useFeedbackDraftStore } from "./draft-store";
|
||||
|
||||
54
packages/core/projects/draft-store.ts
Normal file
54
packages/core/projects/draft-store.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import type { ProjectStatus, ProjectPriority } from "../types";
|
||||
import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "../platform/workspace-storage";
|
||||
import { defaultStorage } from "../platform/storage";
|
||||
|
||||
interface ProjectDraft {
|
||||
title: string;
|
||||
description: string;
|
||||
status: ProjectStatus;
|
||||
priority: ProjectPriority;
|
||||
leadType?: "member" | "agent";
|
||||
leadId?: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
const EMPTY_DRAFT: ProjectDraft = {
|
||||
title: "",
|
||||
description: "",
|
||||
status: "planned",
|
||||
priority: "none",
|
||||
leadType: undefined,
|
||||
leadId: undefined,
|
||||
icon: undefined,
|
||||
};
|
||||
|
||||
interface ProjectDraftStore {
|
||||
draft: ProjectDraft;
|
||||
setDraft: (patch: Partial<ProjectDraft>) => void;
|
||||
clearDraft: () => void;
|
||||
hasDraft: () => boolean;
|
||||
}
|
||||
|
||||
export const useProjectDraftStore = create<ProjectDraftStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
draft: { ...EMPTY_DRAFT },
|
||||
setDraft: (patch) =>
|
||||
set((s) => ({ draft: { ...s.draft, ...patch } })),
|
||||
clearDraft: () =>
|
||||
set({ draft: { ...EMPTY_DRAFT } }),
|
||||
hasDraft: () => {
|
||||
const { draft } = get();
|
||||
return !!(draft.title || draft.description);
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "multica_project_draft",
|
||||
storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
registerForWorkspaceRehydration(() => useProjectDraftStore.persist.rehydrate());
|
||||
@@ -1,2 +1,3 @@
|
||||
export { projectKeys, projectListOptions, projectDetailOptions } from "./queries";
|
||||
export { useCreateProject, useUpdateProject, useDeleteProject } from "./mutations";
|
||||
export { useProjectDraftStore } from "./draft-store";
|
||||
|
||||
@@ -36,6 +36,8 @@ function FileUploadButton({
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
disabled={disabled}
|
||||
aria-label="Attach file"
|
||||
title="Attach file"
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center rounded-full text-muted-foreground hover:bg-accent hover:text-foreground transition-colors disabled:opacity-50 disabled:pointer-events-none",
|
||||
btnSize,
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
import {
|
||||
flexRender,
|
||||
type Header as TanstackHeader,
|
||||
type Row,
|
||||
type Table as TanstackTable,
|
||||
} from "@tanstack/react-table";
|
||||
import type * as React from "react";
|
||||
import * as React from "react";
|
||||
|
||||
// We deliberately use the lower-level shadcn primitives (TableHeader /
|
||||
// TableBody / TableRow / TableHead / TableCell) but NOT the wrapping
|
||||
@@ -48,8 +49,8 @@ interface DataTableProps<TData> extends React.ComponentProps<"div"> {
|
||||
// makes each column's width come from its first row's <th>
|
||||
// inline width. column.size is authoritative for sized columns.
|
||||
// - Columns flagged `meta.grow: true` skip their inline width, so
|
||||
// fixed table-layout assigns them the leftover space (no spacer
|
||||
// column needed).
|
||||
// fixed table-layout assigns them the leftover space until the user
|
||||
// resizes them. Once resized, the explicit width is applied.
|
||||
// - The table's `min-width` is the sum of every column's TanStack
|
||||
// size (`table.getTotalSize()`). That gives grow columns a real
|
||||
// floor — fixed mode ignores cell-level min-width, but it does
|
||||
@@ -64,6 +65,98 @@ export function DataTable<TData>({
|
||||
className,
|
||||
...props
|
||||
}: DataTableProps<TData>) {
|
||||
const [resizingColumnId, setResizingColumnId] = React.useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const columnSizing = table.getState().columnSizing;
|
||||
const hasExplicitSize = React.useCallback(
|
||||
(columnId: string) =>
|
||||
Object.prototype.hasOwnProperty.call(columnSizing, columnId),
|
||||
[columnSizing],
|
||||
);
|
||||
|
||||
const setColumnWidth = React.useCallback(
|
||||
(header: TanstackHeader<TData, unknown>, width: number) => {
|
||||
const minSize = header.column.columnDef.minSize ?? 48;
|
||||
const maxSize =
|
||||
header.column.columnDef.maxSize ?? Number.MAX_SAFE_INTEGER;
|
||||
const next = Math.min(maxSize, Math.max(minSize, Math.round(width)));
|
||||
|
||||
table.setColumnSizing((old) => ({
|
||||
...old,
|
||||
[header.column.id]: next,
|
||||
}));
|
||||
},
|
||||
[table],
|
||||
);
|
||||
|
||||
const beginColumnResize = React.useCallback(
|
||||
(
|
||||
header: TanstackHeader<TData, unknown>,
|
||||
event: React.PointerEvent<HTMLDivElement>,
|
||||
) => {
|
||||
if (!header.column.getCanResize()) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const startX = event.clientX;
|
||||
const headerCell = event.currentTarget.closest("th");
|
||||
const startWidth =
|
||||
headerCell?.getBoundingClientRect().width ?? header.column.getSize();
|
||||
|
||||
setResizingColumnId(header.column.id);
|
||||
setColumnWidth(header, startWidth);
|
||||
|
||||
const originalCursor = document.body.style.cursor;
|
||||
const originalUserSelect = document.body.style.userSelect;
|
||||
document.body.style.cursor = "col-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
|
||||
const handlePointerMove = (pointerEvent: PointerEvent) => {
|
||||
setColumnWidth(header, startWidth + pointerEvent.clientX - startX);
|
||||
};
|
||||
|
||||
const stopResize = () => {
|
||||
window.removeEventListener("pointermove", handlePointerMove);
|
||||
window.removeEventListener("pointerup", stopResize);
|
||||
window.removeEventListener("pointercancel", stopResize);
|
||||
document.body.style.cursor = originalCursor;
|
||||
document.body.style.userSelect = originalUserSelect;
|
||||
setResizingColumnId(null);
|
||||
};
|
||||
|
||||
window.addEventListener("pointermove", handlePointerMove);
|
||||
window.addEventListener("pointerup", stopResize);
|
||||
window.addEventListener("pointercancel", stopResize);
|
||||
},
|
||||
[setColumnWidth],
|
||||
);
|
||||
|
||||
const handleResizeKeyDown = React.useCallback(
|
||||
(
|
||||
header: TanstackHeader<TData, unknown>,
|
||||
event: React.KeyboardEvent<HTMLDivElement>,
|
||||
) => {
|
||||
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const headerCell = event.currentTarget.closest("th");
|
||||
const currentWidth = hasExplicitSize(header.column.id)
|
||||
? header.column.getSize()
|
||||
: (headerCell?.getBoundingClientRect().width ??
|
||||
header.column.getSize());
|
||||
const direction = event.key === "ArrowRight" ? 1 : -1;
|
||||
const step = event.shiftKey ? 20 : 8;
|
||||
|
||||
setColumnWidth(header, currentWidth + direction * step);
|
||||
},
|
||||
[hasExplicitSize, setColumnWidth],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex min-h-0 flex-1 flex-col", className)}
|
||||
@@ -79,6 +172,13 @@ export function DataTable<TData>({
|
||||
<TableRow key={headerGroup.id} className="hover:bg-transparent">
|
||||
{headerGroup.headers.map((header) => {
|
||||
const isPinned = header.column.getIsPinned();
|
||||
const columnHasExplicitSize = hasExplicitSize(
|
||||
header.column.id,
|
||||
);
|
||||
const headerLabel =
|
||||
typeof header.column.columnDef.header === "string"
|
||||
? header.column.columnDef.header
|
||||
: header.column.id;
|
||||
return (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
@@ -98,10 +198,13 @@ export function DataTable<TData>({
|
||||
// into the header strip rather than appearing as
|
||||
// a white block under sticky scroll.
|
||||
className={cn(
|
||||
"h-8 overflow-hidden px-4 py-2 text-xs uppercase tracking-wider text-muted-foreground",
|
||||
"relative h-8 overflow-hidden px-4 py-2 text-xs uppercase tracking-wider text-muted-foreground",
|
||||
isPinned && "bg-muted/30 backdrop-blur",
|
||||
)}
|
||||
style={getCellStyle(header.column, { withBorder: true })}
|
||||
style={getCellStyle(header.column, {
|
||||
withBorder: true,
|
||||
hasExplicitSize: columnHasExplicitSize,
|
||||
})}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
@@ -109,6 +212,33 @@ export function DataTable<TData>({
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
{!header.isPlaceholder &&
|
||||
header.column.getCanResize() && (
|
||||
<div
|
||||
role="separator"
|
||||
aria-label={`Resize ${headerLabel} column`}
|
||||
aria-orientation="vertical"
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
"absolute top-0 right-0 h-full w-2 cursor-col-resize touch-none select-none outline-none",
|
||||
"after:absolute after:top-1/2 after:right-0 after:h-4 after:w-px after:-translate-y-1/2 after:bg-border after:opacity-0 after:transition-opacity",
|
||||
"hover:after:opacity-100 focus-visible:after:opacity-100",
|
||||
resizingColumnId === header.column.id &&
|
||||
"after:bg-primary after:opacity-100",
|
||||
)}
|
||||
onPointerDown={(event) =>
|
||||
beginColumnResize(header, event)
|
||||
}
|
||||
onDoubleClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
header.column.resetSize();
|
||||
}}
|
||||
onKeyDown={(event) =>
|
||||
handleResizeKeyDown(header, event)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
@@ -135,6 +265,9 @@ export function DataTable<TData>({
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
const isPinned = cell.column.getIsPinned();
|
||||
const columnHasExplicitSize = hasExplicitSize(
|
||||
cell.column.id,
|
||||
);
|
||||
return (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
@@ -151,7 +284,10 @@ export function DataTable<TData>({
|
||||
isPinned &&
|
||||
"bg-background group-hover:bg-muted/50",
|
||||
)}
|
||||
style={getCellStyle(cell.column, { withBorder: true })}
|
||||
style={getCellStyle(cell.column, {
|
||||
withBorder: true,
|
||||
hasExplicitSize: columnHasExplicitSize,
|
||||
})}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
|
||||
@@ -4,10 +4,9 @@ import type * as React from "react";
|
||||
// Extend TanStack Table's ColumnMeta with a `grow` flag. TanStack merges
|
||||
// a default `size: 150` into every columnDef, so "no explicit size" can't
|
||||
// be detected by inspecting columnDef.size (it's always a number). Setting
|
||||
// `meta: { grow: true }` is the official extension point — DataTable then
|
||||
// skips the inline width for these columns and lets fixed table-layout
|
||||
// assign them the leftover space (Linear / GitHub-PR-list pattern: title
|
||||
// column grows, others stay at their declared widths).
|
||||
// `meta: { grow: true }` is the official extension point: DataTable skips
|
||||
// the inline width for these columns until the user explicitly resizes them,
|
||||
// then the resized width wins.
|
||||
declare module "@tanstack/react-table" {
|
||||
interface ColumnMeta<TData extends RowData, TValue> {
|
||||
grow?: boolean;
|
||||
@@ -25,10 +24,10 @@ declare module "@tanstack/react-table" {
|
||||
// `group-hover:`.
|
||||
export function getCellStyle<TData>(
|
||||
column: Column<TData>,
|
||||
options?: { withBorder?: boolean },
|
||||
options?: { withBorder?: boolean; hasExplicitSize?: boolean },
|
||||
): React.CSSProperties {
|
||||
const grow = column.columnDef.meta?.grow;
|
||||
const width = grow ? undefined : column.columnDef.size;
|
||||
const width = grow && !options?.hasExplicitSize ? undefined : column.getSize();
|
||||
|
||||
const isPinned = column.getIsPinned();
|
||||
if (!isPinned) {
|
||||
|
||||
@@ -38,18 +38,17 @@ export interface AgentRow {
|
||||
// column.size doubles as the cell's effective max-width: truncatable
|
||||
// cells with `truncate` inside hit ellipsis at the column edge.
|
||||
//
|
||||
// The Agent column has `meta.grow: true` so DataTable skips its inline
|
||||
// `width` — that lets fixed table-layout assign it the leftover space
|
||||
// (= container width − sum of other columns), so the table fills the
|
||||
// viewport without an empty spacer column.
|
||||
// The Agent and Runtime columns have `meta.grow: true` so DataTable skips
|
||||
// their inline widths until the user resizes them. Fixed table-layout splits
|
||||
// the leftover space between them, which keeps Agent from monopolising wide
|
||||
// viewports while still giving both columns a real floor.
|
||||
//
|
||||
// The Agent column also keeps `size: 240` even though it isn't used for
|
||||
// rendering. TanStack folds this into `table.getTotalSize()`, which
|
||||
// DataTable applies as the table's `min-width`. That's how the agent
|
||||
// column gets a real 240px floor: when the viewport drops below
|
||||
// `sum + 240`, the table refuses to shrink further and the container
|
||||
// scrolls instead. (Fixed table-layout ignores cell-level min-width
|
||||
// per spec, so the floor has to live on the table itself.)
|
||||
// The grow columns also keep their `size` values even though those widths
|
||||
// are skipped for initial rendering. TanStack folds them into
|
||||
// `table.getTotalSize()`, which DataTable applies as the table's `min-width`.
|
||||
// That's how the grow columns get real floors: when the viewport drops below
|
||||
// the summed column sizes, the table refuses to shrink further and the
|
||||
// container scrolls instead.
|
||||
const COL_WIDTHS = {
|
||||
agent: 240,
|
||||
status: 120,
|
||||
@@ -102,6 +101,7 @@ export function createAgentColumns({
|
||||
id: "runtime",
|
||||
header: "Runtime",
|
||||
size: COL_WIDTHS.runtime,
|
||||
meta: { grow: true },
|
||||
cell: ({ row }) => <RuntimeCell row={row.original} />,
|
||||
},
|
||||
{
|
||||
@@ -126,6 +126,7 @@ export function createAgentColumns({
|
||||
id: "actions",
|
||||
header: () => null,
|
||||
size: COL_WIDTHS.actions,
|
||||
enableResizing: false,
|
||||
cell: ({ row }) => (
|
||||
<div
|
||||
className="flex justify-end"
|
||||
|
||||
@@ -334,6 +334,7 @@ export function AgentsPage() {
|
||||
data: agentRows,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
enableColumnResizing: true,
|
||||
// Pin the kebab column right so it stays accessible during horizontal
|
||||
// scroll — matches the pattern in Linear / Notion / GitHub.
|
||||
initialState: { columnPinning: { right: ["actions"] } },
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { StatusIcon } from "../../issues/components";
|
||||
import { ActorAvatar } from "../../common/actor-avatar";
|
||||
import { Archive } from "lucide-react";
|
||||
import { Archive, CircleCheck } from "lucide-react";
|
||||
import type { InboxItem } from "@multica/core/types";
|
||||
import { InboxDetailLabel } from "./inbox-detail-label";
|
||||
import { getInboxDisplayTitle } from "./inbox-display";
|
||||
@@ -25,11 +25,13 @@ export function InboxListItem({
|
||||
isSelected,
|
||||
onClick,
|
||||
onArchive,
|
||||
onDone,
|
||||
}: {
|
||||
item: InboxItem;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
onArchive: () => void;
|
||||
onDone?: () => void;
|
||||
}) {
|
||||
const displayTitle = getInboxDisplayTitle(item);
|
||||
|
||||
@@ -59,6 +61,26 @@ export function InboxListItem({
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{onDone && (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
title="Mark as done"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDone();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.stopPropagation();
|
||||
onDone();
|
||||
}
|
||||
}}
|
||||
className="hidden rounded p-0.5 text-muted-foreground hover:bg-accent hover:text-info group-hover:inline-flex"
|
||||
>
|
||||
<CircleCheck className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
useArchiveAllReadInbox,
|
||||
useArchiveCompletedInbox,
|
||||
} from "@multica/core/inbox/mutations";
|
||||
import { useUpdateIssue } from "@multica/core/issues/mutations";
|
||||
import { IssueDetail } from "../../issues/components";
|
||||
import { useNavigation } from "../../navigation";
|
||||
import { toast } from "sonner";
|
||||
@@ -117,6 +118,7 @@ export function InboxPage() {
|
||||
const archiveAllMutation = useArchiveAllInbox();
|
||||
const archiveAllReadMutation = useArchiveAllReadInbox();
|
||||
const archiveCompletedMutation = useArchiveCompletedInbox();
|
||||
const updateIssueMutation = useUpdateIssue();
|
||||
|
||||
// Auto-mark-read whenever a selected item is unread — covers both click-
|
||||
// to-select and URL-param-select (e.g. OS notification click on desktop).
|
||||
@@ -145,6 +147,18 @@ export function InboxPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleDone = (item: InboxItem) => {
|
||||
if (!item.issue_id) return;
|
||||
setSelectedKey("");
|
||||
updateIssueMutation.mutate(
|
||||
{ id: item.issue_id, status: "done" },
|
||||
{ onError: () => toast.error("Failed to mark as done") },
|
||||
);
|
||||
archiveMutation.mutate(item.id, {
|
||||
onError: () => toast.error("Failed to archive"),
|
||||
});
|
||||
};
|
||||
|
||||
// Batch operations
|
||||
const handleMarkAllRead = () => {
|
||||
markAllReadMutation.mutate(undefined, {
|
||||
@@ -235,6 +249,11 @@ export function InboxPage() {
|
||||
isSelected={(item.issue_id ?? item.id) === selectedKey}
|
||||
onClick={() => handleSelect(item)}
|
||||
onArchive={() => handleArchive(item.id)}
|
||||
onDone={
|
||||
item.issue_id && item.issue_status !== "done" && item.issue_status !== "cancelled"
|
||||
? () => handleDone(item)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -258,6 +277,12 @@ export function InboxPage() {
|
||||
// longer exists.
|
||||
setSelectedKey("");
|
||||
}}
|
||||
onDone={() => {
|
||||
setSelectedKey("");
|
||||
archiveMutation.mutate(selected.id, {
|
||||
onError: () => toast.error("Failed to archive"),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
) : selected ? (
|
||||
<div className="p-6">
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
CircleCheck,
|
||||
MoreHorizontal,
|
||||
PanelRight,
|
||||
Pin,
|
||||
@@ -138,6 +139,8 @@ function formatTokenCount(n: number): string {
|
||||
interface IssueDetailProps {
|
||||
issueId: string;
|
||||
onDelete?: () => void;
|
||||
/** Called after the issue is marked as done via the toolbar button. */
|
||||
onDone?: () => void;
|
||||
defaultSidebarOpen?: boolean;
|
||||
layoutId?: string;
|
||||
/** When set, the issue detail will auto-scroll to this comment and briefly highlight it. */
|
||||
@@ -148,7 +151,7 @@ interface IssueDetailProps {
|
||||
// IssueDetail
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layoutId = "multica_issue_detail_layout", highlightCommentId }: IssueDetailProps) {
|
||||
export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = true, layoutId = "multica_issue_detail_layout", highlightCommentId }: IssueDetailProps) {
|
||||
const id = issueId;
|
||||
const router = useNavigation();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
@@ -511,6 +514,23 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{issue.status !== "done" && issue.status !== "cancelled" && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground"
|
||||
onClick={() => { handleUpdateField({ status: "done" }); onDone?.(); }}
|
||||
>
|
||||
<CircleCheck />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="bottom">Mark as done</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
|
||||
@@ -9,6 +9,7 @@ const mockCreateIssue = vi.hoisted(() => vi.fn());
|
||||
const mockSetDraft = vi.hoisted(() => vi.fn());
|
||||
const mockClearDraft = vi.hoisted(() => vi.fn());
|
||||
const mockSetLastAssignee = vi.hoisted(() => vi.fn());
|
||||
const mockSetKeepOpen = vi.hoisted(() => vi.fn());
|
||||
const mockToastCustom = vi.hoisted(() => vi.fn());
|
||||
const mockToastDismiss = vi.hoisted(() => vi.fn());
|
||||
const mockToastError = vi.hoisted(() => vi.fn());
|
||||
@@ -30,6 +31,11 @@ const mockDraftStore = {
|
||||
setLastAssignee: mockSetLastAssignee,
|
||||
};
|
||||
|
||||
const mockQuickCreateStore = {
|
||||
keepOpen: false,
|
||||
setKeepOpen: mockSetKeepOpen,
|
||||
};
|
||||
|
||||
vi.mock("../navigation", () => ({
|
||||
useNavigation: () => ({ push: mockPush }),
|
||||
}));
|
||||
@@ -60,6 +66,11 @@ vi.mock("@multica/core/issues/stores/draft-store", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/issues/stores/quick-create-store", () => ({
|
||||
useQuickCreateStore: (selector?: (state: typeof mockQuickCreateStore) => unknown) =>
|
||||
(selector ? selector(mockQuickCreateStore) : mockQuickCreateStore),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/issues/mutations", () => ({
|
||||
useCreateIssue: () => ({ mutateAsync: mockCreateIssue }),
|
||||
useUpdateIssue: () => ({ mutate: vi.fn() }),
|
||||
@@ -79,6 +90,10 @@ vi.mock("../editor", () => {
|
||||
const [value, setValue] = useState(defaultValue || "");
|
||||
useImperativeHandle(ref, () => ({
|
||||
getMarkdown: () => valueRef.current,
|
||||
clearContent: () => {
|
||||
valueRef.current = "";
|
||||
setValue("");
|
||||
},
|
||||
uploadFile: vi.fn(),
|
||||
}));
|
||||
return (
|
||||
@@ -178,6 +193,23 @@ vi.mock("@multica/ui/components/ui/button", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/ui/components/ui/switch", () => ({
|
||||
Switch: ({
|
||||
checked,
|
||||
onCheckedChange,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onCheckedChange: (v: boolean) => void;
|
||||
}) => (
|
||||
<input
|
||||
aria-label="Create another"
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => onCheckedChange(e.target.checked)}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/ui/components/common/file-upload-button", () => ({
|
||||
FileUploadButton: ({ onSelect }: { onSelect: (file: File) => void }) => (
|
||||
<button type="button" onClick={() => onSelect(new File(["test"], "test.txt"))}>
|
||||
@@ -210,6 +242,10 @@ function renderModal(element: React.ReactElement) {
|
||||
describe("CreateIssueModal", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockQuickCreateStore.keepOpen = false;
|
||||
mockSetKeepOpen.mockImplementation((v: boolean) => {
|
||||
mockQuickCreateStore.keepOpen = v;
|
||||
});
|
||||
mockCreateIssue.mockResolvedValue({
|
||||
id: "issue-123",
|
||||
identifier: "TES-123",
|
||||
@@ -261,4 +297,44 @@ describe("CreateIssueModal", () => {
|
||||
expect(mockPush).toHaveBeenCalledWith("/ws-test/issues/issue-123");
|
||||
expect(mockToastDismiss).toHaveBeenCalledWith("toast-1");
|
||||
});
|
||||
|
||||
it("keeps manual mode open and clears content when create another is enabled", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = vi.fn();
|
||||
mockQuickCreateStore.keepOpen = true;
|
||||
|
||||
renderModal(<CreateIssueModal onClose={onClose} />);
|
||||
|
||||
await user.type(screen.getByPlaceholderText("Issue title"), "First follow-up issue");
|
||||
await user.type(screen.getByPlaceholderText("Add description..."), "Description to clear");
|
||||
await user.click(screen.getByRole("button", { name: "Create Issue" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateIssue).toHaveBeenCalledWith({
|
||||
title: "First follow-up issue",
|
||||
description: "Description to clear",
|
||||
status: "todo",
|
||||
priority: "none",
|
||||
assignee_type: undefined,
|
||||
assignee_id: undefined,
|
||||
due_date: undefined,
|
||||
attachment_ids: undefined,
|
||||
parent_issue_id: undefined,
|
||||
project_id: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
expect(screen.getByPlaceholderText("Issue title")).toHaveValue("");
|
||||
expect(screen.getByPlaceholderText("Add description...")).toHaveValue("");
|
||||
expect(mockSetDraft).toHaveBeenCalledWith({
|
||||
title: "",
|
||||
description: "",
|
||||
status: "todo",
|
||||
priority: "none",
|
||||
assigneeType: undefined,
|
||||
assigneeId: undefined,
|
||||
dueDate: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
} from "@multica/ui/components/ui/dropdown-menu";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Switch } from "@multica/ui/components/ui/switch";
|
||||
import { ContentEditor, type ContentEditorRef, TitleEditor, useFileDropZone, FileDropOverlay } from "../editor";
|
||||
import { StatusIcon, StatusPicker, PriorityPicker, AssigneePicker, DueDatePicker } from "../issues/components";
|
||||
import { BacklogAgentHintContent } from "../issues/components/backlog-agent-hint-dialog";
|
||||
@@ -38,6 +39,7 @@ import { useCurrentWorkspace, useWorkspacePaths } from "@multica/core/paths";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { useIssueDraftStore } from "@multica/core/issues/stores/draft-store";
|
||||
import { useCreateModeStore } from "@multica/core/issues/stores/create-mode-store";
|
||||
import { useQuickCreateStore } from "@multica/core/issues/stores/quick-create-store";
|
||||
import { issueDetailOptions } from "@multica/core/issues/queries";
|
||||
import { useCreateIssue, useUpdateIssue } from "@multica/core/issues/mutations";
|
||||
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
|
||||
@@ -84,8 +86,11 @@ export function ManualCreatePanel({
|
||||
const clearDraft = useIssueDraftStore((s) => s.clearDraft);
|
||||
const setLastAssignee = useIssueDraftStore((s) => s.setLastAssignee);
|
||||
const setLastMode = useCreateModeStore((s) => s.setLastMode);
|
||||
const keepOpen = useQuickCreateStore((s) => s.keepOpen);
|
||||
const setKeepOpen = useQuickCreateStore((s) => s.setKeepOpen);
|
||||
|
||||
const [title, setTitle] = useState(draft.title);
|
||||
const [formResetKey, setFormResetKey] = useState(0);
|
||||
const descEditorRef = useRef<ContentEditorRef>(null);
|
||||
const { isDragOver: descDragOver, dropZoneProps: descDropZoneProps } = useFileDropZone({
|
||||
onDrop: (files) => files.forEach((f) => descEditorRef.current?.uploadFile(f)),
|
||||
@@ -138,6 +143,28 @@ export function ManualCreatePanel({
|
||||
|
||||
const createIssueMutation = useCreateIssue();
|
||||
const updateIssueMutation = useUpdateIssue();
|
||||
const resetForNextIssue = () => {
|
||||
setTitle("");
|
||||
setStatus("todo");
|
||||
setPriority("none");
|
||||
setDueDate(null);
|
||||
setProjectId(undefined);
|
||||
setParentIssueId(undefined);
|
||||
setChildIssues([]);
|
||||
setAttachmentIds([]);
|
||||
setDraft({
|
||||
title: "",
|
||||
description: "",
|
||||
status: "todo",
|
||||
priority: "none",
|
||||
assigneeType,
|
||||
assigneeId,
|
||||
dueDate: null,
|
||||
});
|
||||
descEditorRef.current?.clearContent();
|
||||
setFormResetKey((key) => key + 1);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!title.trim() || submitting) return;
|
||||
setSubmitting(true);
|
||||
@@ -186,6 +213,8 @@ export function ManualCreatePanel({
|
||||
|
||||
if (shouldShowBacklogHint) {
|
||||
setBacklogHintIssueId(issue.id);
|
||||
} else if (keepOpen) {
|
||||
resetForNextIssue();
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
@@ -304,6 +333,7 @@ export function ManualCreatePanel({
|
||||
{/* Title */}
|
||||
<div className="px-5 pb-2 shrink-0">
|
||||
<TitleEditor
|
||||
key={formResetKey}
|
||||
autoFocus
|
||||
defaultValue={draft.title}
|
||||
placeholder="Issue title"
|
||||
@@ -494,20 +524,30 @@ export function ManualCreatePanel({
|
||||
/>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t shrink-0">
|
||||
<FileUploadButton
|
||||
onSelect={(file) => descEditorRef.current?.uploadFile(file)}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-col gap-2 border-t px-4 py-3 shrink-0 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex min-h-7 items-center gap-2">
|
||||
<FileUploadButton
|
||||
onSelect={(file) => descEditorRef.current?.uploadFile(file)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={switchToAgent}
|
||||
title="Switch to create with agent — describe in one line and let the agent file it"
|
||||
className="flex items-center gap-1.5 text-xs px-2 py-1 rounded-sm text-muted-foreground hover:text-foreground hover:bg-accent/60 transition-colors cursor-pointer"
|
||||
className="flex shrink-0 items-center gap-1.5 text-xs px-2 py-1 rounded-sm text-muted-foreground hover:text-foreground hover:bg-accent/60 transition-colors cursor-pointer"
|
||||
>
|
||||
<ArrowLeftRight className="size-3.5" />
|
||||
Switch to agent
|
||||
Switch to Agent
|
||||
</button>
|
||||
<label className="flex shrink-0 items-center gap-1.5 text-xs text-muted-foreground cursor-pointer select-none">
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={keepOpen}
|
||||
onCheckedChange={setKeepOpen}
|
||||
/>
|
||||
Create another
|
||||
</label>
|
||||
<Button size="sm" onClick={handleSubmit} disabled={!title.trim() || submitting}>
|
||||
{submitting ? "Creating..." : "Create Issue"}
|
||||
</Button>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState, useRef } from "react";
|
||||
import { ChevronRight, Maximize2, Minimize2, X as XIcon, UserMinus } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useCreateProject } from "@multica/core/projects/mutations";
|
||||
import { useProjectDraftStore } from "@multica/core/projects";
|
||||
import {
|
||||
PROJECT_STATUS_CONFIG,
|
||||
PROJECT_STATUS_ORDER,
|
||||
@@ -63,17 +64,31 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
|
||||
const { data: agents = [] } = useQuery(agentListOptions(wsId));
|
||||
const { getActorName } = useActorName();
|
||||
|
||||
const [title, setTitle] = useState("");
|
||||
const draft = useProjectDraftStore((s) => s.draft);
|
||||
const setDraft = useProjectDraftStore((s) => s.setDraft);
|
||||
const clearDraft = useProjectDraftStore((s) => s.clearDraft);
|
||||
|
||||
const [title, setTitle] = useState(draft.title);
|
||||
const descEditorRef = useRef<ContentEditorRef>(null);
|
||||
const [status, setStatus] = useState<ProjectStatus>("planned");
|
||||
const [priority, setPriority] = useState<ProjectPriority>("none");
|
||||
const [leadType, setLeadType] = useState<"member" | "agent" | undefined>();
|
||||
const [leadId, setLeadId] = useState<string | undefined>();
|
||||
const [icon, setIcon] = useState<string | undefined>();
|
||||
const [status, setStatus] = useState<ProjectStatus>(draft.status);
|
||||
const [priority, setPriority] = useState<ProjectPriority>(draft.priority);
|
||||
const [leadType, setLeadType] = useState<"member" | "agent" | undefined>(draft.leadType);
|
||||
const [leadId, setLeadId] = useState<string | undefined>(draft.leadId);
|
||||
const [icon, setIcon] = useState<string | undefined>(draft.icon);
|
||||
const [iconPickerOpen, setIconPickerOpen] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
// Sync field changes to draft store
|
||||
const updateTitle = (v: string) => { setTitle(v); setDraft({ title: v }); };
|
||||
const updateStatus = (v: ProjectStatus) => { setStatus(v); setDraft({ status: v }); };
|
||||
const updatePriority = (v: ProjectPriority) => { setPriority(v); setDraft({ priority: v }); };
|
||||
const updateLead = (type?: "member" | "agent", id?: string) => {
|
||||
setLeadType(type); setLeadId(id);
|
||||
setDraft({ leadType: type, leadId: id });
|
||||
};
|
||||
const updateIcon = (v: string | undefined) => { setIcon(v); setDraft({ icon: v }); };
|
||||
|
||||
const [leadOpen, setLeadOpen] = useState(false);
|
||||
const [leadFilter, setLeadFilter] = useState("");
|
||||
|
||||
@@ -100,6 +115,7 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
|
||||
lead_type: leadType,
|
||||
lead_id: leadId,
|
||||
});
|
||||
clearDraft();
|
||||
onClose();
|
||||
toast.success("Project created");
|
||||
router.push(wsPaths.projectDetail(project.id));
|
||||
@@ -177,7 +193,7 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
|
||||
<PopoverContent align="start" className="w-auto p-0">
|
||||
<EmojiPicker
|
||||
onSelect={(emoji) => {
|
||||
setIcon(emoji);
|
||||
updateIcon(emoji);
|
||||
setIconPickerOpen(false);
|
||||
}}
|
||||
/>
|
||||
@@ -185,10 +201,10 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
|
||||
</Popover>
|
||||
<TitleEditor
|
||||
autoFocus
|
||||
defaultValue=""
|
||||
defaultValue={draft.title}
|
||||
placeholder="Project title"
|
||||
className="text-lg font-semibold"
|
||||
onChange={(v) => setTitle(v)}
|
||||
onChange={(v) => updateTitle(v)}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
</div>
|
||||
@@ -196,8 +212,9 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-5">
|
||||
<ContentEditor
|
||||
ref={descEditorRef}
|
||||
defaultValue=""
|
||||
defaultValue={draft.description}
|
||||
placeholder="Add description..."
|
||||
onUpdate={(md) => setDraft({ description: md })}
|
||||
debounceMs={500}
|
||||
/>
|
||||
</div>
|
||||
@@ -214,7 +231,7 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
|
||||
/>
|
||||
<DropdownMenuContent align="start" className="w-44">
|
||||
{PROJECT_STATUS_ORDER.map((s) => (
|
||||
<DropdownMenuItem key={s} onClick={() => setStatus(s)}>
|
||||
<DropdownMenuItem key={s} onClick={() => updateStatus(s)}>
|
||||
<span className={cn("size-2 rounded-full", PROJECT_STATUS_CONFIG[s].dotColor)} />
|
||||
<span>{PROJECT_STATUS_CONFIG[s].label}</span>
|
||||
</DropdownMenuItem>
|
||||
@@ -233,7 +250,7 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
|
||||
/>
|
||||
<DropdownMenuContent align="start" className="w-44">
|
||||
{PROJECT_PRIORITY_ORDER.map((pr) => (
|
||||
<DropdownMenuItem key={pr} onClick={() => setPriority(pr)}>
|
||||
<DropdownMenuItem key={pr} onClick={() => updatePriority(pr)}>
|
||||
<PriorityIcon priority={pr} />
|
||||
<span>{PROJECT_PRIORITY_CONFIG[pr].label}</span>
|
||||
</DropdownMenuItem>
|
||||
@@ -276,8 +293,7 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setLeadType(undefined);
|
||||
setLeadId(undefined);
|
||||
updateLead(undefined, undefined);
|
||||
setLeadOpen(false);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
|
||||
@@ -295,8 +311,7 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
|
||||
type="button"
|
||||
key={m.user_id}
|
||||
onClick={() => {
|
||||
setLeadType("member");
|
||||
setLeadId(m.user_id);
|
||||
updateLead("member", m.user_id);
|
||||
setLeadOpen(false);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
|
||||
@@ -317,8 +332,7 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
|
||||
type="button"
|
||||
key={a.id}
|
||||
onClick={() => {
|
||||
setLeadType("agent");
|
||||
setLeadId(a.id);
|
||||
updateLead("agent", a.id);
|
||||
setLeadOpen(false);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
useFileDropZone,
|
||||
FileDropOverlay,
|
||||
} from "../editor";
|
||||
import { useCreateFeedback } from "@multica/core/feedback";
|
||||
import { useCreateFeedback, useFeedbackDraftStore } from "@multica/core/feedback";
|
||||
import { useCurrentWorkspace } from "@multica/core/paths";
|
||||
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
|
||||
import { api } from "@multica/core/api";
|
||||
@@ -26,8 +26,12 @@ const MAX_MESSAGE_LEN = 10000;
|
||||
|
||||
export function FeedbackModal({ onClose }: { onClose: () => void }) {
|
||||
const workspace = useCurrentWorkspace();
|
||||
const draft = useFeedbackDraftStore((s) => s.draft);
|
||||
const setDraft = useFeedbackDraftStore((s) => s.setDraft);
|
||||
const clearDraft = useFeedbackDraftStore((s) => s.clearDraft);
|
||||
|
||||
const editorRef = useRef<ContentEditorRef>(null);
|
||||
const [message, setMessage] = useState("");
|
||||
const [message, setMessage] = useState(draft.message);
|
||||
const { isDragOver, dropZoneProps } = useFileDropZone({
|
||||
onDrop: (files) => files.forEach((f) => editorRef.current?.uploadFile(f)),
|
||||
});
|
||||
@@ -69,6 +73,7 @@ export function FeedbackModal({ onClose }: { onClose: () => void }) {
|
||||
url: typeof window !== "undefined" ? window.location.href : undefined,
|
||||
workspace_id: workspace?.id,
|
||||
});
|
||||
clearDraft();
|
||||
toast.success("Thanks for the feedback!");
|
||||
onClose();
|
||||
} catch (err) {
|
||||
@@ -98,8 +103,9 @@ export function FeedbackModal({ onClose }: { onClose: () => void }) {
|
||||
>
|
||||
<ContentEditor
|
||||
ref={editorRef}
|
||||
defaultValue={draft.message}
|
||||
placeholder="Tell us about your experience, bugs you've found, or features you'd like to see…"
|
||||
onUpdate={(md) => setMessage(md)}
|
||||
onUpdate={(md) => { setMessage(md); setDraft({ message: md }); }}
|
||||
onUploadFile={uploadWithToast}
|
||||
onSubmit={handleSubmit}
|
||||
debounceMs={150}
|
||||
|
||||
@@ -252,6 +252,7 @@ export function AgentCreatePanel({
|
||||
on the first focusable element on mount, causing the tooltip to
|
||||
auto-pop every open. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
title="Close"
|
||||
aria-label="Close"
|
||||
@@ -268,6 +269,7 @@ export function AgentCreatePanel({
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Select agent"
|
||||
className="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer rounded-sm px-1.5 py-1 -ml-1.5 hover:bg-accent/60"
|
||||
>
|
||||
<span>Created by</span>
|
||||
@@ -307,6 +309,9 @@ export function AgentCreatePanel({
|
||||
size={16}
|
||||
/>
|
||||
<span className="flex-1 truncate">{a.name}</span>
|
||||
{agentId === a.id && (
|
||||
<Check className="size-3.5 text-muted-foreground" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))
|
||||
)}
|
||||
@@ -350,30 +355,30 @@ export function AgentCreatePanel({
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t shrink-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex flex-col gap-2 border-t px-4 py-3 shrink-0 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex min-h-7 items-center gap-2">
|
||||
<FileUploadButton
|
||||
size="sm"
|
||||
disabled={uploading}
|
||||
onSelect={(file) => editorRef.current?.uploadFile(file)}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{keepOpen && sentCount > 0 && (
|
||||
<span className="text-emerald-600 dark:text-emerald-400">{sentCount} sent · </span>
|
||||
)}
|
||||
⌘↵ to submit
|
||||
</span>
|
||||
{keepOpen && sentCount > 0 && (
|
||||
<span className="text-xs text-emerald-600 dark:text-emerald-400">
|
||||
{sentCount} sent
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={switchToManual}
|
||||
title="Switch to manual create — fill the fields yourself"
|
||||
className="flex items-center gap-1.5 text-xs px-2 py-1 rounded-sm text-muted-foreground hover:text-foreground hover:bg-accent/60 transition-colors cursor-pointer"
|
||||
className="flex shrink-0 items-center gap-1.5 text-xs px-2 py-1 rounded-sm text-muted-foreground hover:text-foreground hover:bg-accent/60 transition-colors cursor-pointer"
|
||||
>
|
||||
<ArrowLeftRight className="size-3.5" />
|
||||
Switch to manual
|
||||
Switch to Manual
|
||||
</button>
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer select-none">
|
||||
<label className="flex shrink-0 items-center gap-1.5 text-xs text-muted-foreground cursor-pointer select-none">
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={keepOpen}
|
||||
@@ -390,11 +395,11 @@ export function AgentCreatePanel({
|
||||
? `Daemon CLI must be ≥ ${versionCheck.min}`
|
||||
: undefined
|
||||
}
|
||||
className={justSent ? "!bg-emerald-600 !text-white" : undefined}
|
||||
className={justSent ? "min-w-28 !bg-emerald-600 !text-white" : "min-w-28"}
|
||||
>
|
||||
{submitting ? "Sending…" : uploading ? "Uploading…" : justSent ? (
|
||||
<span className="flex items-center gap-1"><Check className="size-3.5" />Sent</span>
|
||||
) : "Create"}
|
||||
) : "Create (⌘↵)"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
388
packages/views/runtimes/components/connect-remote-dialog.tsx
Normal file
388
packages/views/runtimes/components/connect-remote-dialog.tsx
Normal file
@@ -0,0 +1,388 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Check,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
Loader2,
|
||||
Server,
|
||||
ShieldAlert,
|
||||
Terminal,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { runtimeKeys } from "@multica/core/runtimes/queries";
|
||||
import { useWSEvent } from "@multica/core/realtime";
|
||||
import { paths, useWorkspaceSlug } from "@multica/core/paths";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@multica/ui/components/ui/dialog";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { useNavigation } from "../../navigation";
|
||||
|
||||
type Step = "instructions" | "waiting" | "success";
|
||||
|
||||
export function ConnectRemoteDialog({ onClose }: { onClose: () => void }) {
|
||||
const [step, setStep] = useState<Step>("instructions");
|
||||
const [copied, setCopied] = useState<string | null>(null);
|
||||
const wsId = useWorkspaceId();
|
||||
const slug = useWorkspaceSlug();
|
||||
const qc = useQueryClient();
|
||||
const navigation = useNavigation();
|
||||
const newRuntimeIdRef = useRef<string | null>(null);
|
||||
|
||||
// Listen for a new runtime registration while the dialog is open
|
||||
const handleDaemonRegister = useCallback(
|
||||
(payload: unknown) => {
|
||||
if (step === "waiting" || step === "instructions") {
|
||||
qc.invalidateQueries({ queryKey: runtimeKeys.all(wsId) });
|
||||
const p = payload as Record<string, unknown> | null;
|
||||
if (p?.runtime_id && typeof p.runtime_id === "string") {
|
||||
newRuntimeIdRef.current = p.runtime_id;
|
||||
}
|
||||
setStep("success");
|
||||
}
|
||||
},
|
||||
[step, qc, wsId],
|
||||
);
|
||||
useWSEvent("daemon:register", handleDaemonRegister);
|
||||
|
||||
const copyToClipboard = useCallback(
|
||||
(text: string, key: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopied(key);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!copied) return;
|
||||
const t = setTimeout(() => setCopied(null), 2000);
|
||||
return () => clearTimeout(t);
|
||||
}, [copied]);
|
||||
|
||||
const handleGoToAgents = () => {
|
||||
onClose();
|
||||
if (slug) {
|
||||
navigation.push(paths.workspace(slug).agents());
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoToRuntime = () => {
|
||||
onClose();
|
||||
if (slug && newRuntimeIdRef.current) {
|
||||
navigation.push(
|
||||
paths.workspace(slug).runtimeDetail(newRuntimeIdRef.current),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="flex max-h-[85vh] flex-col sm:max-w-xl">
|
||||
{step === "instructions" && (
|
||||
<InstructionsStep
|
||||
copied={copied}
|
||||
onCopy={copyToClipboard}
|
||||
onNext={() => setStep("waiting")}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)}
|
||||
{step === "waiting" && (
|
||||
<WaitingStep onBack={() => setStep("instructions")} />
|
||||
)}
|
||||
{step === "success" && (
|
||||
<SuccessStep
|
||||
onGoToAgents={handleGoToAgents}
|
||||
onGoToRuntime={
|
||||
newRuntimeIdRef.current ? handleGoToRuntime : undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Step 1: Installation instructions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const INSTALL_CMD = "curl -fsSL https://multica.ai/install.sh | sh";
|
||||
|
||||
const CONFIGURE_CMD = `multica config set server_url https://api.multica.ai
|
||||
multica config set app_url https://multica.ai`;
|
||||
|
||||
const LOGIN_CMD = "multica login --token <YOUR_TOKEN>";
|
||||
|
||||
const START_CMD = `multica daemon start --device-name "my-ec2-instance"
|
||||
multica daemon status`;
|
||||
|
||||
function CodeBlock({
|
||||
code,
|
||||
copyKey,
|
||||
copied,
|
||||
onCopy,
|
||||
}: {
|
||||
code: string;
|
||||
copyKey: string;
|
||||
copied: string | null;
|
||||
onCopy: (text: string, key: string) => void;
|
||||
}) {
|
||||
const isCopied = copied === copyKey;
|
||||
return (
|
||||
<div className="relative rounded-md border bg-muted/50">
|
||||
<pre className="overflow-x-auto p-2.5 pr-10 font-mono text-xs leading-relaxed text-foreground">
|
||||
{code}
|
||||
</pre>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCopy(code, copyKey)}
|
||||
className="absolute top-1.5 right-1.5 flex h-6 w-6 items-center justify-center rounded border bg-background text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
{isCopied ? (
|
||||
<Check className="h-3 w-3 text-success" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InstructionsStep({
|
||||
copied,
|
||||
onCopy,
|
||||
onNext,
|
||||
onClose,
|
||||
}: {
|
||||
copied: string | null;
|
||||
onCopy: (text: string, key: string) => void;
|
||||
onNext: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Connect a remote machine</DialogTitle>
|
||||
<DialogDescription>
|
||||
Run these commands on your remote machine (e.g. AWS EC2) to install the
|
||||
Multica CLI and register it as a runtime.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="-mx-4 min-h-0 flex-1 overflow-y-auto px-4">
|
||||
<div className="space-y-3">
|
||||
{/* Step 1: Install */}
|
||||
<div>
|
||||
<div className="mb-1 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
<Terminal className="h-3.5 w-3.5" />
|
||||
1. Install the CLI
|
||||
</div>
|
||||
<CodeBlock
|
||||
code={INSTALL_CMD}
|
||||
copyKey="install"
|
||||
copied={copied}
|
||||
onCopy={onCopy}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Step 2: Configure */}
|
||||
<div>
|
||||
<div className="mb-1 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
<Server className="h-3.5 w-3.5" />
|
||||
2. Configure
|
||||
</div>
|
||||
<CodeBlock
|
||||
code={CONFIGURE_CMD}
|
||||
copyKey="config"
|
||||
copied={copied}
|
||||
onCopy={onCopy}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Step 3: Login */}
|
||||
<div>
|
||||
<div className="mb-1 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
3. Login with a personal access token
|
||||
</div>
|
||||
<CodeBlock
|
||||
code={LOGIN_CMD}
|
||||
copyKey="login"
|
||||
copied={copied}
|
||||
onCopy={onCopy}
|
||||
/>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground">
|
||||
Create one in{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
Settings → Tokens
|
||||
</span>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Step 4: Start daemon */}
|
||||
<div>
|
||||
<div className="mb-1 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
4. Start the daemon
|
||||
</div>
|
||||
<CodeBlock
|
||||
code={START_CMD}
|
||||
copyKey="start"
|
||||
copied={copied}
|
||||
onCopy={onCopy}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Security tips */}
|
||||
<div className="rounded-md border border-warning/30 bg-warning/5 p-2.5">
|
||||
<div className="flex items-start gap-2">
|
||||
<ShieldAlert className="mt-0.5 h-3.5 w-3.5 shrink-0 text-warning" />
|
||||
<div className="text-[11px] leading-relaxed text-muted-foreground">
|
||||
<span className="font-medium text-foreground">Security: </span>
|
||||
Use an EC2 IAM role or least-privilege credentials. Never put
|
||||
root keys into agent{" "}
|
||||
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[10px]">
|
||||
custom_env
|
||||
</code>
|
||||
. The daemon uses outbound connections only — no inbound ports
|
||||
needed.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Troubleshooting */}
|
||||
<details className="group pb-1">
|
||||
<summary className="flex cursor-pointer items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground">
|
||||
<Wrench className="h-3.5 w-3.5" />
|
||||
Troubleshooting
|
||||
<ChevronRight className="h-3 w-3 transition-transform group-open:rotate-90" />
|
||||
</summary>
|
||||
<ul className="mt-1.5 list-disc space-y-0.5 pl-8 text-[11px] text-muted-foreground">
|
||||
<li>
|
||||
Check status:{" "}
|
||||
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[10px]">
|
||||
multica daemon status
|
||||
</code>
|
||||
</li>
|
||||
<li>
|
||||
View logs:{" "}
|
||||
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[10px]">
|
||||
multica daemon logs -f
|
||||
</code>
|
||||
</li>
|
||||
<li>
|
||||
Verify provider:{" "}
|
||||
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[10px]">
|
||||
claude --version
|
||||
</code>
|
||||
</li>
|
||||
<li>
|
||||
Desktop auto-scans only your local machine. Remote machines must
|
||||
run{" "}
|
||||
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[10px]">
|
||||
multica daemon
|
||||
</code>{" "}
|
||||
separately.
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={onNext}>
|
||||
I've started the daemon
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Step 2: Waiting for registration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function WaitingStep({ onBack }: { onBack: () => void }) {
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Waiting for runtime…</DialogTitle>
|
||||
<DialogDescription>
|
||||
Listening for your remote daemon to register. This page updates
|
||||
automatically — no need to refresh.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col items-center gap-3 py-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Run{" "}
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">
|
||||
multica daemon status
|
||||
</code>{" "}
|
||||
on the remote machine to verify it's running.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={onBack}>
|
||||
Back
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Step 3: Success
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function SuccessStep({
|
||||
onGoToAgents,
|
||||
onGoToRuntime,
|
||||
}: {
|
||||
onGoToAgents: () => void;
|
||||
onGoToRuntime?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Runtime connected!</DialogTitle>
|
||||
<DialogDescription>
|
||||
Your remote machine has registered as a runtime. You can now create an
|
||||
agent that dispatches tasks to it.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col items-center gap-3 py-6">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-success/10">
|
||||
<Check className="h-6 w-6 text-success" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
{onGoToRuntime && (
|
||||
<Button variant="ghost" onClick={onGoToRuntime}>
|
||||
View runtime
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={onGoToAgents}>
|
||||
Create an agent
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -60,12 +60,10 @@ export interface RuntimeRow {
|
||||
canDelete: boolean;
|
||||
}
|
||||
|
||||
// Column widths in px. The Runtime column has `meta.grow: true` so
|
||||
// DataTable skips its inline width — fixed table-layout assigns it the
|
||||
// leftover space. Its `size: 240` still flows into table.getTotalSize()
|
||||
// to set the table's `min-width`, giving the runtime column a 240px
|
||||
// floor below which the container scrolls horizontally instead of
|
||||
// shrinking the column further.
|
||||
// Column widths in px. Runtime, Health, and CLI grow together until the
|
||||
// user resizes them. Their `size` values still flow into table.getTotalSize()
|
||||
// to set the table's min-width, giving each grow column a real floor below
|
||||
// which the container scrolls horizontally instead of shrinking further.
|
||||
const COL_WIDTHS = {
|
||||
runtime: 240,
|
||||
health: 200,
|
||||
@@ -105,6 +103,7 @@ export function createRuntimeColumns({
|
||||
id: "health",
|
||||
header: "Health",
|
||||
size: COL_WIDTHS.health,
|
||||
meta: { grow: true },
|
||||
cell: ({ row }) => (
|
||||
<HealthCell runtime={row.original.runtime} now={now} />
|
||||
),
|
||||
@@ -164,6 +163,7 @@ export function createRuntimeColumns({
|
||||
id: "cli",
|
||||
header: "CLI",
|
||||
size: COL_WIDTHS.cli,
|
||||
meta: { grow: true },
|
||||
cell: ({ row }) => (
|
||||
<CliCell
|
||||
runtime={row.original.runtime}
|
||||
@@ -175,6 +175,7 @@ export function createRuntimeColumns({
|
||||
id: "actions",
|
||||
header: () => null,
|
||||
size: COL_WIDTHS.actions,
|
||||
enableResizing: false,
|
||||
cell: ({ row }) => (
|
||||
<div
|
||||
className="flex justify-end"
|
||||
|
||||
@@ -149,6 +149,7 @@ export function RuntimeList({
|
||||
data: rows,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
enableColumnResizing: true,
|
||||
// Pin the kebab column right so it stays accessible during horizontal
|
||||
// scroll — matches the pattern in Linear / Notion / GitHub.
|
||||
initialState: { columnPinning: { right: ["actions"] } },
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Search, Server } from "lucide-react";
|
||||
import { Plus, Search, Server } from "lucide-react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@multica/ui/components/ui/tooltip";
|
||||
import { PageHeader } from "../../layout/page-header";
|
||||
import { ConnectRemoteDialog } from "./connect-remote-dialog";
|
||||
import { RuntimeList } from "./runtime-list";
|
||||
|
||||
type RuntimeFilter = "mine" | "all";
|
||||
@@ -92,6 +93,7 @@ export function RuntimesPage({ topSlot, bootstrapping }: RuntimesPageProps = {})
|
||||
const [scope, setScope] = useState<RuntimeFilter>("mine");
|
||||
const [healthFilter, setHealthFilter] = useState<HealthFilter>("all");
|
||||
const [search, setSearch] = useState("");
|
||||
const [showConnectDialog, setShowConnectDialog] = useState(false);
|
||||
|
||||
// One unified cache per workspace: scope (Mine/All) is a view filter, not
|
||||
// a fetch dimension. Splitting on owner used to give us two TanStack cache
|
||||
@@ -154,14 +156,17 @@ export function RuntimesPage({ topSlot, bootstrapping }: RuntimesPageProps = {})
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 min-h-0 flex-col">
|
||||
<PageHeaderBar totalCount={totalCount} />
|
||||
<PageHeaderBar
|
||||
totalCount={totalCount}
|
||||
onConnectRemote={() => setShowConnectDialog(true)}
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 min-h-0 flex-col gap-4 p-6">
|
||||
{topSlot}
|
||||
|
||||
{showEmpty ? (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<EmptyState />
|
||||
<EmptyState onConnectRemote={() => setShowConnectDialog(true)} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-1 min-h-0 flex-col overflow-hidden rounded-lg border bg-background">
|
||||
@@ -189,6 +194,10 @@ export function RuntimesPage({ topSlot, bootstrapping }: RuntimesPageProps = {})
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showConnectDialog && (
|
||||
<ConnectRemoteDialog onClose={() => setShowConnectDialog(false)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -198,9 +207,15 @@ export function RuntimesPage({ topSlot, bootstrapping }: RuntimesPageProps = {})
|
||||
// Page-level actions (Search, scope, filter) live in the card below.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function PageHeaderBar({ totalCount }: { totalCount: number }) {
|
||||
function PageHeaderBar({
|
||||
totalCount,
|
||||
onConnectRemote,
|
||||
}: {
|
||||
totalCount: number;
|
||||
onConnectRemote: () => void;
|
||||
}) {
|
||||
return (
|
||||
<PageHeader className="px-5">
|
||||
<PageHeader className="justify-between px-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="h-4 w-4 text-muted-foreground" />
|
||||
<h1 className="text-sm font-medium">Runtimes</h1>
|
||||
@@ -209,9 +224,6 @@ function PageHeaderBar({ totalCount }: { totalCount: number }) {
|
||||
{totalCount}
|
||||
</span>
|
||||
)}
|
||||
{/* Tagline sits right next to the title — same flex group, single
|
||||
sentence + docs link. Hidden below md so it never collides with
|
||||
the title on narrow screens. */}
|
||||
<p className="ml-2 hidden text-xs text-muted-foreground md:block">
|
||||
Machines and cloud workers running CLI sessions for your agents.{" "}
|
||||
<a
|
||||
@@ -224,6 +236,10 @@ function PageHeaderBar({ totalCount }: { totalCount: number }) {
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" size="sm" onClick={onConnectRemote}>
|
||||
<Plus className="h-3 w-3" />
|
||||
Connect remote machine
|
||||
</Button>
|
||||
</PageHeader>
|
||||
);
|
||||
}
|
||||
@@ -413,7 +429,7 @@ function HealthChip({
|
||||
// workspace. Different from "filter matches nothing" (NoMatchesState).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function EmptyState() {
|
||||
function EmptyState({ onConnectRemote }: { onConnectRemote: () => void }) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center px-6 py-16 text-center">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted">
|
||||
@@ -421,12 +437,18 @@ function EmptyState() {
|
||||
</div>
|
||||
<h2 className="mt-4 text-base font-semibold">No runtimes yet</h2>
|
||||
<p className="mt-1 max-w-md text-sm text-muted-foreground">
|
||||
Runtimes register automatically when a daemon connects. Run{" "}
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">
|
||||
multica daemon start
|
||||
</code>{" "}
|
||||
on your machine, or invite a teammate whose daemon is already running.
|
||||
Desktop auto-scans your local machine. For AWS EC2 or other remote
|
||||
machines, connect them using the setup wizard.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={onConnectRemote}
|
||||
className="mt-5"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
Connect remote machine
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -102,6 +102,7 @@ export function createSkillColumns(): ColumnDef<SkillRow>[] {
|
||||
id: "_chevron",
|
||||
header: () => null,
|
||||
size: COL_WIDTHS.chevron,
|
||||
enableResizing: false,
|
||||
cell: () => (
|
||||
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground/40 transition-colors group-hover:text-muted-foreground" />
|
||||
),
|
||||
|
||||
@@ -288,6 +288,7 @@ export default function SkillsPage() {
|
||||
data: skillRows,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
enableColumnResizing: true,
|
||||
});
|
||||
|
||||
// --- Loading ---
|
||||
|
||||
Reference in New Issue
Block a user