mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 11:48:42 +02:00
Compare commits
16 Commits
fix/deskto
...
agent/lamb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bdaab89c8e | ||
|
|
28b29ec5ee | ||
|
|
b98c2a5a0f | ||
|
|
b9118ae9b8 | ||
|
|
06880d6ba2 | ||
|
|
472e78022e | ||
|
|
5bf0e7022d | ||
|
|
665ac39730 | ||
|
|
55b7e2e93a | ||
|
|
80c5bb9e9e | ||
|
|
6a665c68a3 | ||
|
|
174b8c62a6 | ||
|
|
768d3f8b0c | ||
|
|
7dfa72465c | ||
|
|
0b969483a6 | ||
|
|
e024ab1232 |
33
apps/desktop/src/main/app-version.ts
Normal file
33
apps/desktop/src/main/app-version.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { app } from "electron";
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
/**
|
||||
* Resolve the running app version. In packaged builds this is the value
|
||||
* `electron-builder` baked into package.json via `extraMetadata.version`
|
||||
* (driven by `git describe` — see `apps/desktop/scripts/package.mjs`), so
|
||||
* `app.getVersion()` matches the GitHub Release tag exactly.
|
||||
*
|
||||
* In dev (`pnpm dev:desktop`) `app.getVersion()` only sees the static
|
||||
* `apps/desktop/package.json` value, which is "0.1.0" and never bumped —
|
||||
* the Settings → Updates panel and any other UI surfacing the version
|
||||
* would mislead developers into thinking they're running ancient builds.
|
||||
* Fall back to `git describe --tags --always --dirty` (same source the
|
||||
* packager uses) so dev shows e.g. `0.2.19-14-gabcdef-dirty`. If git is
|
||||
* unavailable for whatever reason, we just return the package.json value.
|
||||
*/
|
||||
export function getAppVersion(): string {
|
||||
if (app.isPackaged) {
|
||||
return app.getVersion();
|
||||
}
|
||||
try {
|
||||
const raw = execSync("git describe --tags --always --dirty", {
|
||||
cwd: app.getAppPath(),
|
||||
encoding: "utf-8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim();
|
||||
if (!raw) return app.getVersion();
|
||||
return raw.replace(/^v/, "");
|
||||
} catch {
|
||||
return app.getVersion();
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { setupAutoUpdater } from "./updater";
|
||||
import { setupDaemonManager } from "./daemon-manager";
|
||||
import { openExternalSafely } from "./external-url";
|
||||
import { installContextMenu } from "./context-menu";
|
||||
import { getAppVersion } from "./app-version";
|
||||
|
||||
// Bundled icon used for dev-mode dock/taskbar branding. In production the
|
||||
// app bundle icon (from electron-builder) wins; this path is only consumed
|
||||
@@ -203,7 +204,7 @@ if (!gotTheLock) {
|
||||
ipcMain.on("app:get-info", (event) => {
|
||||
const p = process.platform;
|
||||
const os = p === "darwin" ? "macos" : p === "win32" ? "windows" : p === "linux" ? "linux" : "unknown";
|
||||
event.returnValue = { version: app.getVersion(), os };
|
||||
event.returnValue = { version: getAppVersion(), os };
|
||||
});
|
||||
|
||||
// IPC: toggle immersive mode — hides the macOS traffic lights so full-screen
|
||||
|
||||
@@ -110,18 +110,19 @@ function AppContent() {
|
||||
: undefined;
|
||||
useDaemonIPCBridge(activeWsId);
|
||||
|
||||
// Onboarding and zero-workspace both resolve to an overlay, but
|
||||
// onboarding wins: a user who hasn't completed it gets the onboarding
|
||||
// overlay regardless of how many workspaces already exist.
|
||||
// Workspace presence wins over onboarding state: a user invited into an
|
||||
// existing workspace must enter that workspace, not be trapped in the
|
||||
// onboarding overlay just because their personal `onboarded_at` is null.
|
||||
// Onboarding is only the right destination when the account has zero
|
||||
// workspaces AND has never onboarded.
|
||||
useEffect(() => {
|
||||
if (!user || !workspaceListFetched) return;
|
||||
const { overlay, open } = useWindowOverlayStore.getState();
|
||||
if (overlay) return;
|
||||
if (wsCount > 0) return;
|
||||
if (!hasOnboarded) {
|
||||
open({ type: "onboarding" });
|
||||
return;
|
||||
}
|
||||
if (wsCount === 0) {
|
||||
} else {
|
||||
open({ type: "new-workspace" });
|
||||
}
|
||||
}, [user, workspaceListFetched, wsCount, workspaces, hasOnboarded]);
|
||||
|
||||
@@ -1 +1,38 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
function createMemoryStorage(): Storage {
|
||||
const values = new Map<string, string>();
|
||||
|
||||
return {
|
||||
get length() {
|
||||
return values.size;
|
||||
},
|
||||
clear: () => values.clear(),
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
key: (index: number) => Array.from(values.keys())[index] ?? null,
|
||||
removeItem: (key: string) => {
|
||||
values.delete(key);
|
||||
},
|
||||
setItem: (key: string, value: string) => {
|
||||
values.set(key, value);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const localStorageIsUsable =
|
||||
typeof globalThis.localStorage?.getItem === "function" &&
|
||||
typeof globalThis.localStorage?.setItem === "function" &&
|
||||
typeof globalThis.localStorage?.removeItem === "function" &&
|
||||
typeof globalThis.localStorage?.clear === "function";
|
||||
|
||||
if (!localStorageIsUsable) {
|
||||
const storage = createMemoryStorage();
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
value: storage,
|
||||
});
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
configurable: true,
|
||||
value: storage,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -72,10 +72,6 @@ function LoginPageContent() {
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!hasOnboarded) {
|
||||
router.replace(paths.onboarding());
|
||||
return;
|
||||
}
|
||||
if (nextUrl) {
|
||||
router.replace(nextUrl);
|
||||
return;
|
||||
@@ -89,10 +85,6 @@ function LoginPageContent() {
|
||||
// was captured before login completed and would be stale here.
|
||||
const currentUser = useAuthStore.getState().user;
|
||||
const onboarded = currentUser?.onboarded_at != null;
|
||||
if (!onboarded) {
|
||||
router.push(paths.onboarding());
|
||||
return;
|
||||
}
|
||||
if (nextUrl) {
|
||||
router.push(nextUrl);
|
||||
return;
|
||||
|
||||
@@ -32,20 +32,25 @@ export default function OnboardingPage() {
|
||||
const hasOnboarded = useHasOnboarded();
|
||||
const { data: workspaces = [], isFetched: workspacesFetched } = useQuery({
|
||||
...workspaceListOptions(),
|
||||
enabled: !!user && hasOnboarded,
|
||||
enabled: !!user,
|
||||
});
|
||||
const hasWorkspaces = workspaces.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || !user) {
|
||||
if (!isLoading && !user) router.replace(paths.login());
|
||||
return;
|
||||
}
|
||||
if (hasOnboarded && workspacesFetched) {
|
||||
if (!workspacesFetched) return;
|
||||
// Bounce out if onboarding doesn't apply: either already onboarded, or
|
||||
// the user already has a workspace (e.g. arrived via invitation) — we
|
||||
// never trap an in-workspace user on the onboarding screen.
|
||||
if (hasOnboarded || hasWorkspaces) {
|
||||
router.replace(resolvePostAuthDestination(workspaces, hasOnboarded));
|
||||
}
|
||||
}, [isLoading, user, hasOnboarded, workspacesFetched, workspaces, router]);
|
||||
}, [isLoading, user, hasOnboarded, workspacesFetched, workspaces, hasWorkspaces, router]);
|
||||
|
||||
if (isLoading || !user || hasOnboarded) return null;
|
||||
if (isLoading || !user || hasOnboarded || hasWorkspaces) return null;
|
||||
|
||||
// Layout: page owns its own scroll (root layout sets `body {
|
||||
// overflow: hidden }` for the app-shell convention). OnboardingFlow
|
||||
|
||||
@@ -61,28 +61,54 @@ import CallbackPage from "./page";
|
||||
describe("CallbackPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockSearchParams.forEach((_v, k) => mockSearchParams.delete(k));
|
||||
// Snapshot keys before deleting — forEach + delete skips entries because
|
||||
// the iteration index advances while the underlying list shrinks.
|
||||
Array.from(mockSearchParams.keys()).forEach((k) =>
|
||||
mockSearchParams.delete(k),
|
||||
);
|
||||
mockSearchParams.set("code", "test-code");
|
||||
mockLoginWithGoogle.mockResolvedValue(makeUser());
|
||||
mockListWorkspaces.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("unonboarded user lands on /onboarding regardless of next=", async () => {
|
||||
it("unonboarded user honors a safe next= (e.g. /invite/{id}) so invitees aren't trapped", async () => {
|
||||
mockSearchParams.set("state", "next:/invite/abc123");
|
||||
render(<CallbackPage />);
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith(paths.onboarding());
|
||||
expect(mockPush).toHaveBeenCalledWith("/invite/abc123");
|
||||
});
|
||||
expect(mockPush).not.toHaveBeenCalledWith("/invite/abc123");
|
||||
expect(mockPush).not.toHaveBeenCalledWith(paths.onboarding());
|
||||
});
|
||||
|
||||
it("unonboarded user with no next= also lands on /onboarding", async () => {
|
||||
it("unonboarded user with no next= and zero workspaces lands on /onboarding", async () => {
|
||||
render(<CallbackPage />);
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith(paths.onboarding());
|
||||
});
|
||||
});
|
||||
|
||||
it("unonboarded user with existing workspace lands in that workspace, not /onboarding", async () => {
|
||||
mockListWorkspaces.mockResolvedValue([
|
||||
{
|
||||
id: "ws-1",
|
||||
name: "Acme",
|
||||
slug: "acme",
|
||||
description: null,
|
||||
context: null,
|
||||
settings: {},
|
||||
repos: [],
|
||||
issue_prefix: "ACME",
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
},
|
||||
]);
|
||||
render(<CallbackPage />);
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith(paths.workspace("acme").issues());
|
||||
});
|
||||
expect(mockPush).not.toHaveBeenCalledWith(paths.onboarding());
|
||||
});
|
||||
|
||||
it("onboarded user ignores unsafe next= targets and lands on the default destination", async () => {
|
||||
mockLoginWithGoogle.mockResolvedValue(
|
||||
makeUser({ onboarded_at: "2026-01-01T00:00:00Z" }),
|
||||
|
||||
@@ -66,10 +66,10 @@ function CallbackContent() {
|
||||
const wsList = await api.listWorkspaces();
|
||||
qc.setQueryData(workspaceKeys.list(), wsList);
|
||||
const onboarded = loggedInUser.onboarded_at != null;
|
||||
if (!onboarded) {
|
||||
router.push(paths.onboarding());
|
||||
return;
|
||||
}
|
||||
// Workspace presence beats onboarding state: an invitee with zero
|
||||
// `onboarded_at` but a real workspace must land in that workspace,
|
||||
// not in the new-workspace wizard. A `next=` (e.g. /invite/<id>)
|
||||
// always wins so invite acceptance flows survive auth round-trips.
|
||||
router.push(
|
||||
nextUrl || resolvePostAuthDestination(wsList, onboarded),
|
||||
);
|
||||
|
||||
@@ -15,6 +15,8 @@ import { defaultStorage } from "../../platform/storage";
|
||||
interface QuickCreateState {
|
||||
lastAgentId: string | null;
|
||||
setLastAgentId: (id: string | null) => void;
|
||||
keepOpen: boolean;
|
||||
setKeepOpen: (v: boolean) => void;
|
||||
}
|
||||
|
||||
export const useQuickCreateStore = create<QuickCreateState>()(
|
||||
@@ -22,6 +24,8 @@ export const useQuickCreateStore = create<QuickCreateState>()(
|
||||
(set) => ({
|
||||
lastAgentId: null,
|
||||
setLastAgentId: (id) => set({ lastAgentId: id }),
|
||||
keepOpen: false,
|
||||
setKeepOpen: (v) => set({ keepOpen: v }),
|
||||
}),
|
||||
{
|
||||
name: "multica_quick_create",
|
||||
|
||||
@@ -19,24 +19,24 @@ function makeWs(slug: string): Workspace {
|
||||
}
|
||||
|
||||
describe("resolvePostAuthDestination", () => {
|
||||
it("not onboarded → /onboarding regardless of workspaces", () => {
|
||||
expect(resolvePostAuthDestination([], false)).toBe(paths.onboarding());
|
||||
expect(resolvePostAuthDestination([makeWs("acme")], false)).toBe(
|
||||
paths.onboarding(),
|
||||
);
|
||||
expect(
|
||||
resolvePostAuthDestination([makeWs("acme"), makeWs("beta")], false),
|
||||
).toBe(paths.onboarding());
|
||||
});
|
||||
|
||||
it("onboarded + has workspace → /<first.slug>/issues", () => {
|
||||
it("has workspace → /<first.slug>/issues regardless of onboarded state", () => {
|
||||
const ws = [makeWs("acme"), makeWs("beta")];
|
||||
expect(resolvePostAuthDestination(ws, true)).toBe(
|
||||
paths.workspace("acme").issues(),
|
||||
);
|
||||
expect(resolvePostAuthDestination(ws, false)).toBe(
|
||||
paths.workspace("acme").issues(),
|
||||
);
|
||||
expect(resolvePostAuthDestination([makeWs("acme")], false)).toBe(
|
||||
paths.workspace("acme").issues(),
|
||||
);
|
||||
});
|
||||
|
||||
it("onboarded + zero workspaces → /workspaces/new", () => {
|
||||
it("zero workspaces + !onboarded → /onboarding", () => {
|
||||
expect(resolvePostAuthDestination([], false)).toBe(paths.onboarding());
|
||||
});
|
||||
|
||||
it("zero workspaces + onboarded → /workspaces/new", () => {
|
||||
expect(resolvePostAuthDestination([], true)).toBe(paths.newWorkspace());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,19 +4,23 @@ import { paths } from "./paths";
|
||||
|
||||
/**
|
||||
* Priority:
|
||||
* !hasOnboarded → /onboarding
|
||||
* hasOnboarded && has workspace → /<first.slug>/issues
|
||||
* hasOnboarded && zero workspaces → /workspaces/new
|
||||
* has workspace → /<first.slug>/issues
|
||||
* zero workspaces && !hasOnboarded → /onboarding
|
||||
* zero workspaces && hasOnboarded → /workspaces/new
|
||||
*
|
||||
* Workspace presence wins over onboarding state: a user invited into an
|
||||
* existing workspace must NOT be bounced into the new-workspace wizard
|
||||
* just because their personal `onboarded_at` is still null.
|
||||
*/
|
||||
export function resolvePostAuthDestination(
|
||||
workspaces: Workspace[],
|
||||
hasOnboarded: boolean,
|
||||
): string {
|
||||
if (!hasOnboarded) {
|
||||
return paths.onboarding();
|
||||
}
|
||||
const first = workspaces[0];
|
||||
return first ? paths.workspace(first.slug).issues() : paths.newWorkspace();
|
||||
if (first) {
|
||||
return paths.workspace(first.slug).issues();
|
||||
}
|
||||
return hasOnboarded ? paths.newWorkspace() : paths.onboarding();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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"] } },
|
||||
|
||||
@@ -41,13 +41,12 @@ export function ModelPicker({
|
||||
);
|
||||
const supported = modelsQuery.data?.supported ?? true;
|
||||
// Memoise the model list so every downstream useMemo gets a stable
|
||||
// reference — `?? []` would mint a fresh array on every render and
|
||||
// invalidate filters / defaultModel needlessly.
|
||||
// reference; `?? []` would mint a fresh array on every render and
|
||||
// invalidate filters needlessly.
|
||||
const models = useMemo(
|
||||
() => modelsQuery.data?.models ?? [],
|
||||
[modelsQuery.data],
|
||||
);
|
||||
const defaultModel = useMemo(() => models.find((m) => m.default), [models]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const s = search.trim().toLowerCase();
|
||||
@@ -78,9 +77,7 @@ export function ModelPicker({
|
||||
);
|
||||
}
|
||||
|
||||
const triggerLabel =
|
||||
value ||
|
||||
(defaultModel ? `Default — ${defaultModel.label}` : "Default");
|
||||
const triggerLabel = value || "Default";
|
||||
const triggerTitle = `Model · ${triggerLabel}`;
|
||||
|
||||
return (
|
||||
|
||||
@@ -42,7 +42,6 @@ export function ModelDropdown({
|
||||
|
||||
const supported = modelsQuery.data?.supported ?? true;
|
||||
const models = modelsQuery.data?.models ?? [];
|
||||
const defaultModel = useMemo(() => models.find((m) => m.default), [models]);
|
||||
const grouped = useMemo(() => groupByProvider(models), [models]);
|
||||
|
||||
// When the selected runtime reports it doesn't support per-agent
|
||||
@@ -86,9 +85,7 @@ export function ModelDropdown({
|
||||
(disabled
|
||||
? "Select a runtime first"
|
||||
: runtimeOnline
|
||||
? defaultModel
|
||||
? `Default — ${defaultModel.label}`
|
||||
: "Default (provider)"
|
||||
? "Default (provider)"
|
||||
: "Runtime offline — enter manually");
|
||||
|
||||
if (!supported && !modelsQuery.isLoading) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { STATUS_CONFIG, PRIORITY_CONFIG } from "@multica/core/issues/config";
|
||||
import { useActorName } from "@multica/core/workspace/hooks";
|
||||
import { StatusIcon, PriorityIcon } from "../../issues/components";
|
||||
import type { InboxItem, InboxItemType, IssueStatus, IssuePriority } from "@multica/core/types";
|
||||
import { getQuickCreateFailureDetail } from "./inbox-display";
|
||||
|
||||
const typeLabels: Record<InboxItemType, string> = {
|
||||
issue_assigned: "Assigned",
|
||||
@@ -20,8 +21,8 @@ const typeLabels: Record<InboxItemType, string> = {
|
||||
agent_blocked: "Agent blocked",
|
||||
agent_completed: "Agent completed",
|
||||
reaction_added: "Reacted",
|
||||
quick_create_done: "Quick create done",
|
||||
quick_create_failed: "Quick create failed",
|
||||
quick_create_done: "Created with agent",
|
||||
quick_create_failed: "Create with agent failed",
|
||||
};
|
||||
|
||||
export { typeLabels };
|
||||
@@ -88,6 +89,16 @@ export function InboxDetailLabel({ item }: { item: InboxItem }) {
|
||||
if (emoji) return <span>Reacted {emoji} to your comment</span>;
|
||||
return <span>{typeLabels[item.type]}</span>;
|
||||
}
|
||||
case "quick_create_done": {
|
||||
const identifier = details.identifier;
|
||||
if (identifier) return <span>Created with agent: {identifier}</span>;
|
||||
return <span>{typeLabels[item.type]}</span>;
|
||||
}
|
||||
case "quick_create_failed": {
|
||||
const detail = getQuickCreateFailureDetail(item);
|
||||
if (detail) return <span>Failed: {detail}</span>;
|
||||
return <span>{typeLabels[item.type]}</span>;
|
||||
}
|
||||
default:
|
||||
return <span>{typeLabels[item.type] ?? item.type}</span>;
|
||||
}
|
||||
|
||||
80
packages/views/inbox/components/inbox-display.test.ts
Normal file
80
packages/views/inbox/components/inbox-display.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { InboxItem } from "@multica/core/types";
|
||||
import {
|
||||
getInboxDisplayTitle,
|
||||
getQuickCreateFailureDetail,
|
||||
stripQuickCreatePrefix,
|
||||
} from "./inbox-display";
|
||||
|
||||
function item(overrides: Partial<InboxItem>): InboxItem {
|
||||
return {
|
||||
id: "inbox-1",
|
||||
workspace_id: "workspace-1",
|
||||
recipient_type: "member",
|
||||
recipient_id: "member-1",
|
||||
actor_type: "agent",
|
||||
actor_id: "agent-1",
|
||||
type: "new_comment",
|
||||
severity: "info",
|
||||
issue_id: "issue-1",
|
||||
title: "Issue title",
|
||||
body: null,
|
||||
issue_status: null,
|
||||
read: false,
|
||||
archived: false,
|
||||
created_at: "2026-04-29T12:00:00Z",
|
||||
details: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("inbox display helpers", () => {
|
||||
it("removes legacy quick-create created prefixes from list titles", () => {
|
||||
expect(
|
||||
stripQuickCreatePrefix(
|
||||
"Created MUL-1583: Fix agent list column widths",
|
||||
"MUL-1583",
|
||||
),
|
||||
).toBe("Fix agent list column widths");
|
||||
});
|
||||
|
||||
it("cleans quick-create success titles before rendering the inbox row", () => {
|
||||
const quickCreateItem = item({
|
||||
type: "quick_create_done",
|
||||
title: "Created MUL-1583: Fix agent list column widths",
|
||||
details: { identifier: "MUL-1583" },
|
||||
});
|
||||
|
||||
expect(getInboxDisplayTitle(quickCreateItem)).toBe(
|
||||
"Fix agent list column widths",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the original prompt as the failed quick-create row title", () => {
|
||||
const failedItem = item({
|
||||
type: "quick_create_failed",
|
||||
title: "Quick create failed",
|
||||
body: "agent finished without creating an issue",
|
||||
issue_id: null,
|
||||
details: {
|
||||
original_prompt: "Optimize QuickCapture UI\nand attached screenshot",
|
||||
},
|
||||
});
|
||||
|
||||
expect(getInboxDisplayTitle(failedItem)).toBe(
|
||||
"Optimize QuickCapture UI and attached screenshot",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the redacted failure detail for failed quick-create subtitles", () => {
|
||||
const failedItem = item({
|
||||
type: "quick_create_failed",
|
||||
body: "fallback body",
|
||||
details: { error: "CLI failed\nwith exit status 1" },
|
||||
});
|
||||
|
||||
expect(getQuickCreateFailureDetail(failedItem)).toBe(
|
||||
"CLI failed with exit status 1",
|
||||
);
|
||||
});
|
||||
});
|
||||
49
packages/views/inbox/components/inbox-display.ts
Normal file
49
packages/views/inbox/components/inbox-display.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { InboxItem } from "@multica/core/types";
|
||||
|
||||
function singleLine(value: string | null | undefined): string {
|
||||
return (value ?? "").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
export function stripQuickCreatePrefix(title: string, identifier?: string): string {
|
||||
const normalized = singleLine(title);
|
||||
if (!normalized) return "";
|
||||
|
||||
if (identifier) {
|
||||
const exactPrefix = new RegExp(
|
||||
`^Created\\s+${escapeRegExp(identifier)}:\\s*`,
|
||||
"i",
|
||||
);
|
||||
const withoutExactPrefix = normalized.replace(exactPrefix, "");
|
||||
if (withoutExactPrefix !== normalized) return withoutExactPrefix.trim();
|
||||
}
|
||||
|
||||
return normalized.replace(/^Created\s+[A-Z][A-Z0-9]*-\d+:\s*/i, "").trim();
|
||||
}
|
||||
|
||||
export function getInboxDisplayTitle(item: InboxItem): string {
|
||||
const details = item.details ?? {};
|
||||
|
||||
if (item.type === "quick_create_done") {
|
||||
const cleanedTitle = stripQuickCreatePrefix(item.title, details.identifier);
|
||||
if (cleanedTitle) return cleanedTitle;
|
||||
|
||||
const prompt = singleLine(details.original_prompt);
|
||||
if (prompt) return prompt;
|
||||
}
|
||||
|
||||
if (item.type === "quick_create_failed") {
|
||||
const prompt = singleLine(details.original_prompt);
|
||||
if (prompt) return prompt;
|
||||
}
|
||||
|
||||
return item.title;
|
||||
}
|
||||
|
||||
export function getQuickCreateFailureDetail(item: InboxItem): string {
|
||||
const details = item.details ?? {};
|
||||
return singleLine(details.error) || singleLine(item.body);
|
||||
}
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
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";
|
||||
|
||||
function timeAgo(dateStr: string): string {
|
||||
const diff = Date.now() - new Date(dateStr).getTime();
|
||||
@@ -24,12 +25,16 @@ export function InboxListItem({
|
||||
isSelected,
|
||||
onClick,
|
||||
onArchive,
|
||||
onDone,
|
||||
}: {
|
||||
item: InboxItem;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
onArchive: () => void;
|
||||
onDone?: () => void;
|
||||
}) {
|
||||
const displayTitle = getInboxDisplayTitle(item);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
@@ -52,10 +57,30 @@ export function InboxListItem({
|
||||
<span
|
||||
className={`truncate text-sm ${!item.read ? "font-medium" : "text-muted-foreground"}`}
|
||||
>
|
||||
{item.title}
|
||||
{displayTitle}
|
||||
</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";
|
||||
@@ -51,6 +52,7 @@ import { useIsMobile } from "@multica/ui/hooks/use-mobile";
|
||||
import { PageHeader } from "../../layout/page-header";
|
||||
import { InboxListItem, timeAgo } from "./inbox-list-item";
|
||||
import { typeLabels } from "./inbox-detail-label";
|
||||
import { getInboxDisplayTitle } from "./inbox-display";
|
||||
|
||||
export function InboxPage() {
|
||||
const { searchParams, replace } = useNavigation();
|
||||
@@ -116,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).
|
||||
@@ -144,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, {
|
||||
@@ -234,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>
|
||||
@@ -257,10 +277,16 @@ export function InboxPage() {
|
||||
// longer exists.
|
||||
setSelectedKey("");
|
||||
}}
|
||||
onDone={() => {
|
||||
setSelectedKey("");
|
||||
archiveMutation.mutate(selected.id, {
|
||||
onError: () => toast.error("Failed to archive"),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
) : selected ? (
|
||||
<div className="p-6">
|
||||
<h2 className="text-lg font-semibold">{selected.title}</h2>
|
||||
<h2 className="text-lg font-semibold">{getInboxDisplayTitle(selected)}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{typeLabels[selected.type]} · {timeAgo(selected.created_at)}
|
||||
</p>
|
||||
|
||||
@@ -399,14 +399,6 @@ describe("IssueDetail (shared)", () => {
|
||||
expect(screen.getByDisplayValue("Add JWT auth to the backend")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders issue identifier in the breadcrumb", async () => {
|
||||
renderIssueDetail();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("TES-1")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders workspace name as breadcrumb link", async () => {
|
||||
renderIssueDetail();
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -506,14 +509,28 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
<ChevronRight className="h-3 w-3 text-muted-foreground/50 shrink-0" />
|
||||
</>
|
||||
)}
|
||||
<span className="shrink-0 text-muted-foreground">
|
||||
{issue.identifier}
|
||||
</span>
|
||||
<span className="truncate font-medium text-foreground">
|
||||
{issue.title}
|
||||
</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={
|
||||
|
||||
@@ -560,7 +560,7 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
className="text-muted-foreground"
|
||||
onClick={() => useModalStore.getState().open("create-issue")}
|
||||
onClick={() => useModalStore.getState().open("quick-create-issue")}
|
||||
>
|
||||
<span className="relative">
|
||||
<SquarePen />
|
||||
|
||||
@@ -21,8 +21,14 @@ import { useNavigation } from "../navigation";
|
||||
* - Not logged in → /login
|
||||
* - Logged in but workspace list not yet loaded → wait (don't bounce prematurely)
|
||||
* - Logged in but URL slug doesn't resolve to any workspace →
|
||||
* `resolvePostAuthDestination(list, hasOnboarded)` — onboarding for
|
||||
* first-timers, /workspaces/new for returning users who deleted out.
|
||||
* `resolvePostAuthDestination(list, hasOnboarded)` — first workspace if any,
|
||||
* onboarding for first-timers, /workspaces/new for returning users who
|
||||
* deleted out.
|
||||
*
|
||||
* Onboarding is NOT a separate gate: a user invited into a workspace can have
|
||||
* `onboarded_at == null` yet legitimately belong inside a workspace, and must
|
||||
* not be bounced to the new-workspace wizard. The onboarding redirect only
|
||||
* fires from the resolver when the user has zero workspaces.
|
||||
*
|
||||
* We read the workspace list query state directly (rather than relying on
|
||||
* useCurrentWorkspace's null return) so we can distinguish "list loading"
|
||||
@@ -47,10 +53,6 @@ export function useDashboardGuard() {
|
||||
return;
|
||||
}
|
||||
if (!workspaceListFetched) return;
|
||||
if (!hasOnboarded) {
|
||||
replace(paths.onboarding());
|
||||
return;
|
||||
}
|
||||
if (!workspace) {
|
||||
replace(resolvePostAuthDestination(workspaces, hasOnboarded));
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ArrowLeftRight, ChevronRight, X as XIcon } from "lucide-react";
|
||||
import { ArrowLeftRight, Check, ChevronRight, X as XIcon } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { DialogTitle } from "@multica/ui/components/ui/dialog";
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@multica/ui/components/ui/dropdown-menu";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Switch } from "@multica/ui/components/ui/switch";
|
||||
import { api, ApiError } from "@multica/core/api";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { useCurrentWorkspace } from "@multica/core/paths";
|
||||
@@ -37,6 +38,7 @@ import {
|
||||
useFileDropZone,
|
||||
FileDropOverlay,
|
||||
} from "../editor";
|
||||
import { FileUploadButton } from "@multica/ui/components/common/file-upload-button";
|
||||
|
||||
// AgentCreatePanel — agent-mode body of the create-issue dialog. Renders
|
||||
// only the inner content; the surrounding `<Dialog>` AND `<DialogContent>`
|
||||
@@ -78,6 +80,8 @@ export function AgentCreatePanel({
|
||||
|
||||
const lastAgentId = useQuickCreateStore((s) => s.lastAgentId);
|
||||
const setLastAgentId = useQuickCreateStore((s) => s.setLastAgentId);
|
||||
const keepOpen = useQuickCreateStore((s) => s.keepOpen);
|
||||
const setKeepOpen = useQuickCreateStore((s) => s.setKeepOpen);
|
||||
const setLastMode = useCreateModeStore((s) => s.setLastMode);
|
||||
|
||||
const [agentId, setAgentId] = useState<string | undefined>(() => {
|
||||
@@ -130,12 +134,14 @@ export function AgentCreatePanel({
|
||||
const editorRef = useRef<ContentEditorRef>(null);
|
||||
const [hasContent, setHasContent] = useState(initialPrompt.trim().length > 0);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [justSent, setJustSent] = useState(false);
|
||||
const [sentCount, setSentCount] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Image paste/drop support: route uploads through the same helper Advanced
|
||||
// uses, so users can paste screenshots straight into the prompt and the
|
||||
// agent receives them as embedded markdown image URLs in the prompt.
|
||||
const { uploadWithToast } = useFileUpload(api);
|
||||
const { uploadWithToast, uploading } = useFileUpload(api);
|
||||
const handleUploadFile = useCallback(
|
||||
(file: File) => uploadWithToast(file),
|
||||
[uploadWithToast],
|
||||
@@ -154,7 +160,7 @@ export function AgentCreatePanel({
|
||||
|
||||
const submit = async () => {
|
||||
const md = editorRef.current?.getMarkdown()?.trim() ?? "";
|
||||
if (!md || !agentId || submitting || versionBlocked) return;
|
||||
if (!md || !agentId || submitting || versionBlocked || uploading) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
@@ -164,7 +170,18 @@ export function AgentCreatePanel({
|
||||
toast.success("Sent to agent — you'll get an inbox notification when it's done", {
|
||||
duration: 4000,
|
||||
});
|
||||
onClose();
|
||||
if (keepOpen) {
|
||||
// Stay open for continuous creation — clear the editor so the
|
||||
// user can immediately type the next prompt.
|
||||
editorRef.current?.clearContent();
|
||||
setHasContent(false);
|
||||
setSentCount((c) => c + 1);
|
||||
setJustSent(true);
|
||||
setTimeout(() => setJustSent(false), 1500);
|
||||
requestAnimationFrame(() => editorRef.current?.focus());
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
} catch (e) {
|
||||
// Server returns 422 with { code, ... } for the structured rejection
|
||||
// paths the modal cares about. Surface the reason in-modal so the
|
||||
@@ -235,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"
|
||||
@@ -251,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>
|
||||
@@ -290,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>
|
||||
))
|
||||
)}
|
||||
@@ -333,29 +355,51 @@ export function AgentCreatePanel({
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t shrink-0">
|
||||
<span className="text-xs text-muted-foreground">⌘↵ to submit</span>
|
||||
<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
|
||||
size="sm"
|
||||
disabled={uploading}
|
||||
onSelect={(file) => editorRef.current?.uploadFile(file)}
|
||||
/>
|
||||
{keepOpen && sentCount > 0 && (
|
||||
<span className="text-xs text-emerald-600 dark:text-emerald-400">
|
||||
{sentCount} sent
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<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 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={submit}
|
||||
disabled={!hasContent || !agentId || submitting || versionBlocked}
|
||||
disabled={!hasContent || !agentId || submitting || versionBlocked || uploading}
|
||||
title={
|
||||
versionBlocked
|
||||
? `Daemon CLI must be ≥ ${versionCheck.min}`
|
||||
: undefined
|
||||
}
|
||||
className={justSent ? "min-w-28 !bg-emerald-600 !text-white" : "min-w-28"}
|
||||
>
|
||||
{submitting ? "Sending…" : "Create"}
|
||||
{submitting ? "Sending…" : uploading ? "Uploading…" : justSent ? (
|
||||
<span className="flex items-center gap-1"><Check className="size-3.5" />Sent</span>
|
||||
) : "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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -261,7 +261,7 @@ describe("SearchCommand", () => {
|
||||
);
|
||||
await user.click(newIssue);
|
||||
|
||||
expect(mockOpenModal).toHaveBeenCalledWith("create-issue");
|
||||
expect(mockOpenModal).toHaveBeenCalledWith("quick-create-issue");
|
||||
expect(useSearchStore.getState().open).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
@@ -202,7 +202,7 @@ export function SearchCommand() {
|
||||
icon: Plus,
|
||||
keywords: ["new", "issue", "create", "add"],
|
||||
onSelect: () => {
|
||||
useModalStore.getState().open("create-issue");
|
||||
useModalStore.getState().open("quick-create-issue");
|
||||
setOpen(false);
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
@@ -1424,10 +1424,11 @@ func (s *TaskService) notifyQuickCreateCompleted(ctx context.Context, task db.Ag
|
||||
prefix := s.getIssuePrefix(workspaceID)
|
||||
identifier := fmt.Sprintf("%s-%d", prefix, issue.Number)
|
||||
details, _ := json.Marshal(map[string]any{
|
||||
"task_id": util.UUIDToString(task.ID),
|
||||
"agent_id": util.UUIDToString(task.AgentID),
|
||||
"issue_id": util.UUIDToString(issue.ID),
|
||||
"identifier": identifier,
|
||||
"task_id": util.UUIDToString(task.ID),
|
||||
"agent_id": util.UUIDToString(task.AgentID),
|
||||
"issue_id": util.UUIDToString(issue.ID),
|
||||
"identifier": identifier,
|
||||
"original_prompt": qc.Prompt,
|
||||
})
|
||||
item, err := s.Queries.CreateInboxItem(ctx, db.CreateInboxItemParams{
|
||||
WorkspaceID: workspaceID,
|
||||
@@ -1436,7 +1437,7 @@ func (s *TaskService) notifyQuickCreateCompleted(ctx context.Context, task db.Ag
|
||||
Type: "quick_create_done",
|
||||
Severity: "info",
|
||||
IssueID: issue.ID,
|
||||
Title: fmt.Sprintf("Created %s: %s", identifier, issue.Title),
|
||||
Title: issue.Title,
|
||||
Body: pgtype.Text{},
|
||||
ActorType: pgtype.Text{String: "agent", Valid: true},
|
||||
ActorID: task.AgentID,
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"build": {
|
||||
"dependsOn": ["^build"],
|
||||
"inputs": ["src/**", "app/**", "**/*.ts", "**/*.tsx", "**/*.css"],
|
||||
"outputs": [".next/**", "!.next/cache/**", "dist/**"]
|
||||
"outputs": [".next/**", "!.next/cache/**", "dist/**", "out/**"]
|
||||
},
|
||||
"dev": {
|
||||
"cache": false,
|
||||
|
||||
Reference in New Issue
Block a user