mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 11:48:42 +02:00
Compare commits
1 Commits
quick-crea
...
refactor/c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75c9a5c742 |
36
packages/core/issues/stores/create-mode-store.ts
Normal file
36
packages/core/issues/stores/create-mode-store.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import { defaultStorage } from "../../platform/storage";
|
||||
|
||||
/**
|
||||
* Last create-issue mode the user landed on. Drives the global `c` shortcut
|
||||
* and the in-modal mode switch — pressing `c` opens whichever modal the user
|
||||
* used last, and the switch button in either modal updates this so the
|
||||
* preference sticks.
|
||||
*
|
||||
* Workspace-agnostic on purpose: the user's mental preference for "how do I
|
||||
* file an issue" doesn't change per workspace, so this lives in plain
|
||||
* localStorage rather than the workspace-aware StateStorage that scopes
|
||||
* per-workspace stores like quick-create-store / draft-store.
|
||||
*/
|
||||
export type CreateMode = "agent" | "manual";
|
||||
|
||||
interface CreateModeState {
|
||||
lastMode: CreateMode;
|
||||
setLastMode: (mode: CreateMode) => void;
|
||||
}
|
||||
|
||||
export const useCreateModeStore = create<CreateModeState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
lastMode: "agent",
|
||||
setLastMode: (mode) => set({ lastMode: mode }),
|
||||
}),
|
||||
{
|
||||
name: "multica_create_mode",
|
||||
storage: createJSONStorage(() => defaultStorage),
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -1,4 +1,5 @@
|
||||
export { useIssueSelectionStore } from "./selection-store";
|
||||
export { useCreateModeStore, type CreateMode } from "./create-mode-store";
|
||||
export { useIssueDraftStore } from "./draft-store";
|
||||
export { useRecentIssuesStore, type RecentIssueEntry } from "./recent-issues-store";
|
||||
export {
|
||||
|
||||
@@ -38,6 +38,7 @@ import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/
|
||||
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@multica/ui/components/ui/collapsible";
|
||||
import { StatusIcon } from "../issues/components/status-icon";
|
||||
import { useIssueDraftStore } from "@multica/core/issues/stores/draft-store";
|
||||
import { useCreateModeStore } from "@multica/core/issues/stores/create-mode-store";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
@@ -395,13 +396,14 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
|
||||
},
|
||||
});
|
||||
|
||||
// Global "C" shortcut: opens the quick-create modal by default; Shift+C
|
||||
// jumps straight to the legacy advanced form for users who want every
|
||||
// field. Both branches honor the same focus / open-modal guards.
|
||||
// Global "C" shortcut: opens whichever create mode the user landed on last
|
||||
// (agent vs manual), persisted in useCreateModeStore. The mode switch lives
|
||||
// inside both modal footers so users can flip without remembering which
|
||||
// shortcut goes where — `c` always means "open the create flow I prefer".
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== "c" && e.key !== "C") return;
|
||||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
if (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return;
|
||||
const tag = (e.target as HTMLElement)?.tagName;
|
||||
const isEditable =
|
||||
tag === "INPUT" ||
|
||||
@@ -411,10 +413,11 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
|
||||
if (isEditable) return;
|
||||
if (useModalStore.getState().modal) return;
|
||||
e.preventDefault();
|
||||
// Auto-fill project when on a project detail page (advanced form only —
|
||||
// quick-create lets the agent infer project from prompt).
|
||||
const projectMatch = pathname.match(/^\/[^/]+\/projects\/([^/]+)$/);
|
||||
if (e.shiftKey) {
|
||||
const lastMode = useCreateModeStore.getState().lastMode;
|
||||
if (lastMode === "manual") {
|
||||
// Auto-fill project when on a project detail page (manual form only —
|
||||
// agent mode lets the agent infer project from the prompt).
|
||||
const projectMatch = pathname.match(/^\/[^/]+\/projects\/([^/]+)$/);
|
||||
const data = projectMatch ? { project_id: projectMatch[1] } : undefined;
|
||||
useModalStore.getState().open("create-issue", data);
|
||||
} else {
|
||||
|
||||
94
packages/views/modals/create-issue-dialog.tsx
Normal file
94
packages/views/modals/create-issue-dialog.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { Dialog, DialogContent } from "@multica/ui/components/ui/dialog";
|
||||
import {
|
||||
useCreateModeStore,
|
||||
type CreateMode,
|
||||
} from "@multica/core/issues/stores/create-mode-store";
|
||||
import { AgentCreatePanel } from "./quick-create-issue";
|
||||
import { ManualCreatePanel, manualDialogContentClass } from "./create-issue";
|
||||
|
||||
/**
|
||||
* Shell that owns the single `<Dialog>` AND `<DialogContent>` for the
|
||||
* create-issue flow. Mode switching unmounts/mounts only the inner panel
|
||||
* body — the Portal, Backdrop, and Popup all stay in the DOM, so Base UI
|
||||
* never replays the open animation. That's what makes the switch feel
|
||||
* instant; an earlier version put `<DialogContent>` inside each panel and
|
||||
* the close→open animation cycle still fired on every toggle.
|
||||
*
|
||||
* `initialMode` comes from the modal registry (`quick-create-issue` →
|
||||
* agent, `create-issue` → manual). Subsequent switches are local state
|
||||
* only and never round-trip through the modal store.
|
||||
*
|
||||
* Carry payload: when a panel switches mode it can hand a payload up via
|
||||
* `onSwitchMode`; the shell stores it as the next panel's `data` so seeding
|
||||
* works exactly like a fresh open.
|
||||
*
|
||||
* Manual-mode `isExpanded` / `backlogHintIssueId` are lifted up because they
|
||||
* drive `DialogContent`'s className — the className lives here in the shell
|
||||
* since the Popup is here, but the toggles for those states live in the
|
||||
* manual panel body.
|
||||
*/
|
||||
export function CreateIssueDialog({
|
||||
onClose,
|
||||
initialMode,
|
||||
data,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
initialMode: CreateMode;
|
||||
data?: Record<string, unknown> | null;
|
||||
}) {
|
||||
const setLastMode = useCreateModeStore((s) => s.setLastMode);
|
||||
const [mode, setMode] = useState<CreateMode>(initialMode);
|
||||
const [panelData, setPanelData] = useState(data ?? null);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [backlogHintIssueId, setBacklogHintIssueId] = useState<string | null>(null);
|
||||
|
||||
const switchTo = (next: CreateMode) => (carry?: Record<string, unknown> | null) => {
|
||||
setLastMode(next);
|
||||
setPanelData(carry ?? null);
|
||||
setMode(next);
|
||||
};
|
||||
|
||||
const className =
|
||||
mode === "agent"
|
||||
? cn(
|
||||
"p-0 gap-0 flex flex-col overflow-hidden",
|
||||
"!top-1/2 !left-1/2 !-translate-x-1/2 !-translate-y-1/2",
|
||||
"!max-w-xl !w-full",
|
||||
// Smooth size transition when switching modes — the manual mode
|
||||
// uses the same easing.
|
||||
"!transition-all !duration-300 !ease-out",
|
||||
)
|
||||
: manualDialogContentClass(isExpanded, backlogHintIssueId);
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(v) => { if (!v) onClose(); }}>
|
||||
<DialogContent
|
||||
finalFocus={false}
|
||||
showCloseButton={false}
|
||||
className={className}
|
||||
>
|
||||
{mode === "agent" ? (
|
||||
<AgentCreatePanel
|
||||
onClose={onClose}
|
||||
onSwitchMode={switchTo("manual")}
|
||||
data={panelData}
|
||||
/>
|
||||
) : (
|
||||
<ManualCreatePanel
|
||||
onClose={onClose}
|
||||
onSwitchMode={switchTo("agent")}
|
||||
data={panelData}
|
||||
isExpanded={isExpanded}
|
||||
setIsExpanded={setIsExpanded}
|
||||
backlogHintIssueId={backlogHintIssueId}
|
||||
setBacklogHintIssueId={setBacklogHintIssueId}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { useNavigation } from "../navigation";
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowLeftRight,
|
||||
ArrowUp,
|
||||
Check,
|
||||
ChevronRight,
|
||||
@@ -17,7 +18,6 @@ import { cn } from "@multica/ui/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
import type { Issue, IssueStatus, IssuePriority, IssueAssigneeType } from "@multica/core/types";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from "@multica/ui/components/ui/dialog";
|
||||
@@ -37,6 +37,7 @@ import { ProjectPicker } from "../projects/components/project-picker";
|
||||
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 { issueDetailOptions } from "@multica/core/issues/queries";
|
||||
import { useCreateIssue, useUpdateIssue } from "@multica/core/issues/mutations";
|
||||
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
|
||||
@@ -46,10 +47,34 @@ import { PillButton } from "../common/pill-button";
|
||||
import { IssuePickerModal } from "./issue-picker-modal";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CreateIssueModal
|
||||
// ManualCreatePanel — manual-mode body of the create-issue dialog. Renders
|
||||
// DialogContent + everything inside; the surrounding `<Dialog>` is owned by
|
||||
// CreateIssueDialog so mode switching swaps only the inner panel without
|
||||
// remounting the Dialog Root (no overlay flash). `onSwitchMode` flips the
|
||||
// shell's local mode state.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?: Record<string, unknown> | null }) {
|
||||
export function ManualCreatePanel({
|
||||
onClose,
|
||||
onSwitchMode,
|
||||
data,
|
||||
isExpanded,
|
||||
setIsExpanded,
|
||||
backlogHintIssueId,
|
||||
setBacklogHintIssueId,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
/** Called with the carry payload to seed the agent panel after switch. */
|
||||
onSwitchMode?: (carry?: Record<string, unknown> | null) => void;
|
||||
data?: Record<string, unknown> | null;
|
||||
/** Lifted to the shell so DialogContent's mode-aware className can react
|
||||
* without the body itself having to live inside DialogContent (which would
|
||||
* re-mount the Portal on mode swap and replay the open animation). */
|
||||
isExpanded: boolean;
|
||||
setIsExpanded: (v: boolean) => void;
|
||||
backlogHintIssueId: string | null;
|
||||
setBacklogHintIssueId: (id: string | null) => void;
|
||||
}) {
|
||||
const router = useNavigation();
|
||||
const p = useWorkspacePaths();
|
||||
const workspaceName = useCurrentWorkspace()?.name;
|
||||
@@ -58,6 +83,7 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
|
||||
const setDraft = useIssueDraftStore((s) => s.setDraft);
|
||||
const clearDraft = useIssueDraftStore((s) => s.clearDraft);
|
||||
const setLastAssignee = useIssueDraftStore((s) => s.setLastAssignee);
|
||||
const setLastMode = useCreateModeStore((s) => s.setLastMode);
|
||||
|
||||
const [title, setTitle] = useState(draft.title);
|
||||
const descEditorRef = useRef<ContentEditorRef>(null);
|
||||
@@ -81,9 +107,6 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
|
||||
// object, and we never need to hydrate from an ID the way we do for parent.
|
||||
const [childIssues, setChildIssues] = useState<Issue[]>([]);
|
||||
const [childPickerOpen, setChildPickerOpen] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [backlogHintIssueId, setBacklogHintIssueId] = useState<string | null>(null);
|
||||
|
||||
// Fetch parent issue details for the chip (status/identifier/title).
|
||||
// List cache usually has it already, so this resolves synchronously.
|
||||
const wsId = useWorkspaceId();
|
||||
@@ -155,6 +178,7 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
|
||||
}
|
||||
|
||||
setLastAssignee(assigneeType, assigneeId);
|
||||
setLastMode("manual");
|
||||
clearDraft();
|
||||
const shouldShowBacklogHint =
|
||||
status === "backlog" && assigneeType === "agent" && assigneeId &&
|
||||
@@ -199,32 +223,25 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
|
||||
}
|
||||
};
|
||||
|
||||
// Switch to agent mode. Hand the typed text up to the shell as the carry
|
||||
// payload; the shell stores it as the next panel's `data` so the agent
|
||||
// panel reads `data.prompt` on mount. Concatenate title + description so
|
||||
// nothing the user typed is lost — the agent derives a fresh title from
|
||||
// the combined text. Persist the mode flip so the next `c` lands in agent.
|
||||
const switchToAgent = () => {
|
||||
const desc = descEditorRef.current?.getMarkdown()?.trim() ?? "";
|
||||
const prompt = [title.trim(), desc].filter(Boolean).join("\n\n");
|
||||
setLastMode("agent");
|
||||
onSwitchMode?.({
|
||||
prompt,
|
||||
...(assigneeType === "agent" && assigneeId
|
||||
? { agent_id: assigneeId }
|
||||
: {}),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onOpenChange={(v) => {
|
||||
if (!v) {
|
||||
setBacklogHintIssueId(null);
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
finalFocus={false}
|
||||
showCloseButton={false}
|
||||
className={cn(
|
||||
"p-0 gap-0 flex flex-col overflow-hidden",
|
||||
"!top-1/2 !left-1/2 !-translate-x-1/2",
|
||||
backlogHintIssueId
|
||||
? "!max-w-[480px] !w-[calc(100vw-2rem)] !h-auto !-translate-y-1/2 !transition-none !duration-0"
|
||||
: "!transition-all !duration-300 !ease-out",
|
||||
!backlogHintIssueId && isExpanded
|
||||
? "!max-w-4xl !w-full !h-5/6 !-translate-y-1/2"
|
||||
: !backlogHintIssueId
|
||||
? "!max-w-2xl !w-full !h-96 !-translate-y-1/2"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
<>
|
||||
{backlogHintIssueId ? (
|
||||
<BacklogAgentHintContent
|
||||
onKeepInBacklog={() => {
|
||||
@@ -252,7 +269,7 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<span className="text-muted-foreground">{workspaceName}</span>
|
||||
<ChevronRight className="size-3 text-muted-foreground/50" />
|
||||
<span className="font-medium">New issue</span>
|
||||
<span className="font-medium">Create manually</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Tooltip>
|
||||
@@ -481,13 +498,74 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
|
||||
<FileUploadButton
|
||||
onSelect={(file) => descEditorRef.current?.uploadFile(file)}
|
||||
/>
|
||||
<Button size="sm" onClick={handleSubmit} disabled={!title.trim() || submitting}>
|
||||
{submitting ? "Creating..." : "Create Issue"}
|
||||
</Button>
|
||||
<div className="flex items-center 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"
|
||||
>
|
||||
<ArrowLeftRight className="size-3.5" />
|
||||
Switch to agent
|
||||
</button>
|
||||
<Button size="sm" onClick={handleSubmit} disabled={!title.trim() || submitting}>
|
||||
{submitting ? "Creating..." : "Create Issue"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** className for DialogContent in manual mode — depends on isExpanded and the
|
||||
* backlog-hint sub-state. Exported so the shell (which now owns the
|
||||
* DialogContent) can apply the same visual treatment without duplicating it. */
|
||||
export function manualDialogContentClass(
|
||||
isExpanded: boolean,
|
||||
backlogHintIssueId: string | null,
|
||||
) {
|
||||
return cn(
|
||||
"p-0 gap-0 flex flex-col overflow-hidden",
|
||||
"!top-1/2 !left-1/2 !-translate-x-1/2",
|
||||
backlogHintIssueId
|
||||
? "!max-w-[480px] !w-[calc(100vw-2rem)] !h-auto !-translate-y-1/2 !transition-none !duration-0"
|
||||
: "!transition-all !duration-300 !ease-out",
|
||||
!backlogHintIssueId && isExpanded
|
||||
? "!max-w-4xl !w-full !h-5/6 !-translate-y-1/2"
|
||||
: !backlogHintIssueId
|
||||
? "!max-w-2xl !w-full !h-96 !-translate-y-1/2"
|
||||
: "",
|
||||
);
|
||||
}
|
||||
|
||||
// Thin Dialog-wrapping export — registry mounts the panel directly under the
|
||||
// shell's shared Dialog, but a few legacy callers (and the test suite) still
|
||||
// import this module's modal version. Equivalent runtime behavior to the
|
||||
// pre-refactor component when used standalone.
|
||||
import { Dialog as DialogRoot } from "@multica/ui/components/ui/dialog";
|
||||
export function CreateIssueModal(props: {
|
||||
onClose: () => void;
|
||||
data?: Record<string, unknown> | null;
|
||||
}) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [backlogHintIssueId, setBacklogHintIssueId] = useState<string | null>(null);
|
||||
return (
|
||||
<DialogRoot open onOpenChange={(v) => { if (!v) props.onClose(); }}>
|
||||
<DialogContent
|
||||
finalFocus={false}
|
||||
showCloseButton={false}
|
||||
className={manualDialogContentClass(isExpanded, backlogHintIssueId)}
|
||||
>
|
||||
<ManualCreatePanel
|
||||
{...props}
|
||||
isExpanded={isExpanded}
|
||||
setIsExpanded={setIsExpanded}
|
||||
backlogHintIssueId={backlogHintIssueId}
|
||||
setBacklogHintIssueId={setBacklogHintIssueId}
|
||||
/>
|
||||
</DialogContent>
|
||||
</DialogRoot>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ChevronRight, Sparkles, X as XIcon } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ArrowLeftRight, ChevronRight, Sparkles, X as XIcon } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from "@multica/ui/components/ui/dialog";
|
||||
import { DialogTitle } from "@multica/ui/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@multica/ui/components/ui/dropdown-menu";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@multica/ui/components/ui/tooltip";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { api, ApiError } from "@multica/core/api";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
@@ -24,22 +18,36 @@ import { useCurrentWorkspace } from "@multica/core/paths";
|
||||
import { agentListOptions } from "@multica/core/workspace/queries";
|
||||
import { useQuickCreateStore } from "@multica/core/issues/stores/quick-create-store";
|
||||
import { useIssueDraftStore } from "@multica/core/issues/stores/draft-store";
|
||||
import { useModalStore } from "@multica/core/modals";
|
||||
import { useCreateModeStore } from "@multica/core/issues/stores/create-mode-store";
|
||||
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
|
||||
import type { Agent } from "@multica/core/types";
|
||||
import { ActorAvatar } from "../common/actor-avatar";
|
||||
import { canAssignAgent } from "../issues/components/pickers/assignee-picker";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { memberListOptions } from "@multica/core/workspace/queries";
|
||||
import {
|
||||
ContentEditor,
|
||||
type ContentEditorRef,
|
||||
useFileDropZone,
|
||||
FileDropOverlay,
|
||||
} from "../editor";
|
||||
|
||||
// QuickCreateIssueModal — a streamlined create-issue UI: pick an agent, type
|
||||
// one line, submit. The agent translates the line into a `multica issue
|
||||
// create` call asynchronously; the modal closes immediately and the user is
|
||||
// notified via inbox when the agent finishes.
|
||||
export function QuickCreateIssueModal({
|
||||
// AgentCreatePanel — agent-mode body of the create-issue dialog. Renders
|
||||
// only the inner content; the surrounding `<Dialog>` AND `<DialogContent>`
|
||||
// (Portal + Overlay + Popup) are owned by CreateIssueDialog so mode-switching
|
||||
// swaps only this body. Lifting the Portal is what eliminates the close→open
|
||||
// animation flash — Base UI replays Popup enter/exit when DialogContent is
|
||||
// remounted, even inside a still-open Dialog Root.
|
||||
//
|
||||
// `onSwitchMode` is wired by the shell — the panel calls it (no payload from
|
||||
// agent → manual; the shared draft store carries description + agent).
|
||||
export function AgentCreatePanel({
|
||||
onClose,
|
||||
onSwitchMode,
|
||||
data,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
onSwitchMode?: () => void;
|
||||
data?: Record<string, unknown> | null;
|
||||
}) {
|
||||
const workspaceName = useCurrentWorkspace()?.name;
|
||||
@@ -64,6 +72,7 @@ export function QuickCreateIssueModal({
|
||||
|
||||
const lastAgentId = useQuickCreateStore((s) => s.lastAgentId);
|
||||
const setLastAgentId = useQuickCreateStore((s) => s.setLastAgentId);
|
||||
const setLastMode = useCreateModeStore((s) => s.setLastMode);
|
||||
|
||||
const [agentId, setAgentId] = useState<string | undefined>(() => {
|
||||
const seed = (data?.agent_id as string) || lastAgentId || undefined;
|
||||
@@ -89,22 +98,43 @@ export function QuickCreateIssueModal({
|
||||
);
|
||||
|
||||
const initialPrompt = (data?.prompt as string) || "";
|
||||
const [prompt, setPrompt] = useState(initialPrompt);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
// The editor is uncontrolled — we read the latest markdown via the ref at
|
||||
// submit/switch time. `hasContent` mirrors emptiness so the Create button
|
||||
// can disable correctly without a controlled-input rerender on every keystroke.
|
||||
const editorRef = useRef<ContentEditorRef>(null);
|
||||
const [hasContent, setHasContent] = useState(initialPrompt.trim().length > 0);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
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 handleUploadFile = useCallback(
|
||||
(file: File) => uploadWithToast(file),
|
||||
[uploadWithToast],
|
||||
);
|
||||
const { isDragOver, dropZoneProps } = useFileDropZone({
|
||||
onDrop: (files) => files.forEach((f) => editorRef.current?.uploadFile(f)),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
textareaRef.current?.focus();
|
||||
// Defer focus so it lands after the dialog's focus trap has settled —
|
||||
// otherwise the trap can bounce focus back to the first focusable header
|
||||
// button on the next tick.
|
||||
const id = requestAnimationFrame(() => editorRef.current?.focus());
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, []);
|
||||
|
||||
const submit = async () => {
|
||||
if (!prompt.trim() || !agentId || submitting) return;
|
||||
const md = editorRef.current?.getMarkdown()?.trim() ?? "";
|
||||
if (!md || !agentId || submitting) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.quickCreateIssue({ agent_id: agentId, prompt: prompt.trim() });
|
||||
await api.quickCreateIssue({ agent_id: agentId, prompt: md });
|
||||
setLastAgentId(agentId);
|
||||
setLastMode("agent");
|
||||
toast.success("Sent to agent — you'll get an inbox notification when it's done", {
|
||||
duration: 4000,
|
||||
});
|
||||
@@ -127,39 +157,26 @@ export function QuickCreateIssueModal({
|
||||
}
|
||||
};
|
||||
|
||||
// Switch to the legacy advanced form, carrying the prompt over as the
|
||||
// description so the user doesn't lose what they typed. The picked agent
|
||||
// becomes the default assignee candidate (still editable). We seed the
|
||||
// shared issue-draft store directly because the legacy modal reads its
|
||||
// initial values from there rather than from `data`.
|
||||
const switchToAdvanced = () => {
|
||||
// Switch to the manual form, carrying what the user typed over as the
|
||||
// description (markdown, including any pasted images) so they don't lose
|
||||
// their work. The picked agent becomes the default assignee candidate
|
||||
// (still editable). We seed the shared issue-draft store directly because
|
||||
// the manual panel reads its initial values from there. Persist the mode
|
||||
// flip so the next `c` lands in manual.
|
||||
const switchToManual = () => {
|
||||
const md = editorRef.current?.getMarkdown() ?? "";
|
||||
useIssueDraftStore.getState().setDraft({
|
||||
description: prompt,
|
||||
description: md,
|
||||
...(agentId
|
||||
? { assigneeType: "agent" as const, assigneeId: agentId }
|
||||
: {}),
|
||||
});
|
||||
useModalStore.getState().open("create-issue");
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}
|
||||
setLastMode("manual");
|
||||
onSwitchMode?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(v) => { if (!v) onClose(); }}>
|
||||
<DialogContent
|
||||
finalFocus={false}
|
||||
showCloseButton={false}
|
||||
className={cn(
|
||||
"p-0 gap-0 flex flex-col overflow-hidden",
|
||||
"!top-1/2 !left-1/2 !-translate-x-1/2 !-translate-y-1/2",
|
||||
"!max-w-xl !w-full",
|
||||
)}
|
||||
>
|
||||
<>
|
||||
<DialogTitle className="sr-only">Quick create issue</DialogTitle>
|
||||
|
||||
{/* Header */}
|
||||
@@ -167,38 +184,20 @@ export function QuickCreateIssueModal({
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<span className="text-muted-foreground">{workspaceName}</span>
|
||||
<ChevronRight className="size-3 text-muted-foreground/50" />
|
||||
<span className="font-medium">Quick create</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<button
|
||||
onClick={switchToAdvanced}
|
||||
className="text-xs px-2 py-1 rounded-sm opacity-70 hover:opacity-100 hover:bg-accent/60 transition-all cursor-pointer"
|
||||
>
|
||||
Advanced
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="bottom">
|
||||
Open the full form with all fields
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-sm p-1.5 opacity-70 hover:opacity-100 hover:bg-accent/60 transition-all cursor-pointer"
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="bottom">Close</TooltipContent>
|
||||
</Tooltip>
|
||||
<span className="font-medium">Create with agent</span>
|
||||
</div>
|
||||
{/* Native `title` instead of Base UI Tooltip — Tooltip opens on
|
||||
keyboard focus, and the dialog's focus trap briefly lands focus
|
||||
on the first focusable element on mount, causing the tooltip to
|
||||
auto-pop every open. */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
title="Close"
|
||||
aria-label="Close"
|
||||
className="rounded-sm p-1.5 opacity-70 hover:opacity-100 hover:bg-accent/60 transition-all cursor-pointer"
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Agent picker */}
|
||||
@@ -255,17 +254,23 @@ export function QuickCreateIssueModal({
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* Prompt textarea */}
|
||||
<div className="px-5 pb-3">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
{/* Prompt — same rich editor Advanced uses, so paste/drop images,
|
||||
mentions, and formatting all work. The dropZone wrapper enables
|
||||
drag-and-drop file uploads alongside paste. */}
|
||||
<div
|
||||
{...dropZoneProps}
|
||||
className="relative px-5 pb-3 min-h-[140px]"
|
||||
>
|
||||
<ContentEditor
|
||||
ref={editorRef}
|
||||
defaultValue={initialPrompt}
|
||||
placeholder='Describe the issue, e.g. "fix inbox loading slowness, assign to naiyuan, P1"'
|
||||
rows={5}
|
||||
className="w-full resize-none bg-transparent text-sm placeholder:text-muted-foreground focus:outline-none"
|
||||
onUpdate={(md) => setHasContent(md.trim().length > 0)}
|
||||
onUploadFile={handleUploadFile}
|
||||
onSubmit={submit}
|
||||
debounceMs={150}
|
||||
/>
|
||||
{isDragOver && <FileDropOverlay />}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
@@ -275,15 +280,25 @@ export function QuickCreateIssueModal({
|
||||
{/* 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>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={submit}
|
||||
disabled={!prompt.trim() || !agentId || submitting}
|
||||
>
|
||||
{submitting ? "Sending…" : "Create"}
|
||||
</Button>
|
||||
<div className="flex items-center 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"
|
||||
>
|
||||
<ArrowLeftRight className="size-3.5" />
|
||||
Switch to manual
|
||||
</button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={submit}
|
||||
disabled={!hasContent || !agentId || submitting}
|
||||
>
|
||||
{submitting ? "Sending…" : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
import { useModalStore } from "@multica/core/modals";
|
||||
import { CreateWorkspaceModal } from "./create-workspace";
|
||||
import { CreateIssueModal } from "./create-issue";
|
||||
import { QuickCreateIssueModal } from "./quick-create-issue";
|
||||
import { CreateIssueDialog } from "./create-issue-dialog";
|
||||
import { CreateProjectModal } from "./create-project";
|
||||
import { FeedbackModal } from "./feedback";
|
||||
import { SetParentIssueModal } from "./set-parent-issue";
|
||||
@@ -19,10 +18,12 @@ export function ModalRegistry() {
|
||||
switch (modal) {
|
||||
case "create-workspace":
|
||||
return <CreateWorkspaceModal onClose={close} />;
|
||||
// Both modal types open the same shell so the in-modal mode switch is
|
||||
// instant — only the inner panel swaps, the Dialog Root stays mounted.
|
||||
case "create-issue":
|
||||
return <CreateIssueModal onClose={close} data={data} />;
|
||||
return <CreateIssueDialog onClose={close} initialMode="manual" data={data} />;
|
||||
case "quick-create-issue":
|
||||
return <QuickCreateIssueModal onClose={close} data={data} />;
|
||||
return <CreateIssueDialog onClose={close} initialMode="agent" data={data} />;
|
||||
case "create-project":
|
||||
return <CreateProjectModal onClose={close} />;
|
||||
case "feedback":
|
||||
|
||||
Reference in New Issue
Block a user