mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-02 01:45:52 +02:00
* feat(quick-create): add project picker that remembers last pick Quick-create users targeting one project repeatedly had to restate "in project X" in every prompt. The modal now exposes a project picker beside the agent picker, persists the selection per-workspace, and pins the agent's `multica issue create` invocation to that project so the prompt text doesn't have to. The picked project also flows to the daemon as ProjectID/ProjectTitle and its github_repo resources override the workspace repo fallback — same treatment issue-bound tasks already get. Co-authored-by: multica-agent <github@multica.ai> * fix(quick-create): move project picker into property pill row Reviewer feedback: the picker felt out of place wedged next to the agent header. Move it into a property toolbar row above the footer, reusing the shared `ProjectPicker` + `PillButton` so its placement and styling line up exactly with the manual create panel. This also drops the bespoke dropdown / aria / label strings that were only needed while the picker rendered inline beside "Created by". Co-authored-by: multica-agent <github@multica.ai> * fix(quick-create): clear stale persisted project + carry across mode switch Two review-blocking bugs in PR #2321: 1. The stale-id sweep in AgentCreatePanel only fired when projects.length > 0 and only cleared local state, leaving lastProjectId pointing at a deleted project. The next open re-seeded the dead UUID and submit hit the server's `project not found` rejection. Gate on the query's `isSuccess` so we can tell "loading" apart from "loaded as empty", and clear both local state and the persisted preference when the selection isn't in the resolved list. 2. ManualCreatePanel's switchToAgent dropped the picked project from the carry payload, so flipping manual → agent silently fell back to the agent panel's own lastProjectId — potentially routing the issue to a different project than the one shown in manual mode. Forward project_id alongside prompt / agent_id, and add a regression test. Co-authored-by: multica-agent <github@multica.ai> * test(quick-create): pass new isExpanded props in stale-project tests Main got an expand button on AgentCreatePanel via #2320 while this branch was open, adding `isExpanded` / `setIsExpanded` to the panel's required props. The two new stale-project tests still passed `{ onClose }` only, which CI's typecheck (run on the main+branch merge) caught while my local run did not. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: multica-agent <github@multica.ai>
506 lines
22 KiB
TypeScript
506 lines
22 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { ArrowLeftRight, Check, ChevronRight, Maximize2, Minimize2, X as XIcon } from "lucide-react";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { toast } from "sonner";
|
|
import { DialogTitle } from "@multica/ui/components/ui/dialog";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
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";
|
|
import { agentListOptions } from "@multica/core/workspace/queries";
|
|
import { projectListOptions } from "@multica/core/projects/queries";
|
|
import { useQuickCreateStore } from "@multica/core/issues/stores/quick-create-store";
|
|
import { useIssueDraftStore } from "@multica/core/issues/stores/draft-store";
|
|
import { useCreateModeStore } from "@multica/core/issues/stores/create-mode-store";
|
|
import {
|
|
runtimeListOptions,
|
|
checkQuickCreateCliVersion,
|
|
readRuntimeCliVersion,
|
|
MIN_QUICK_CREATE_CLI_VERSION,
|
|
} from "@multica/core/runtimes";
|
|
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
|
|
import { formatShortcut, modKey, enterKey } from "@multica/core/platform";
|
|
import type { Agent } from "@multica/core/types";
|
|
import { ActorAvatar } from "../common/actor-avatar";
|
|
import { PillButton } from "../common/pill-button";
|
|
import { ProjectPicker } from "../projects/components/project-picker";
|
|
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";
|
|
import { FileUploadButton } from "@multica/ui/components/common/file-upload-button";
|
|
import { useT } from "../i18n";
|
|
|
|
// 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 with an optional
|
|
// carry payload (currently `project_id`). The shared draft store carries the
|
|
// description + agent across the agent→manual flip; project_id rides through
|
|
// the same carry channel manual→agent uses, so the manual panel reads it
|
|
// from `data?.project_id` without a parallel store.
|
|
export function AgentCreatePanel({
|
|
onClose,
|
|
onSwitchMode,
|
|
data,
|
|
isExpanded,
|
|
setIsExpanded,
|
|
}: {
|
|
onClose: () => void;
|
|
onSwitchMode?: (carry?: Record<string, unknown> | null) => void;
|
|
data?: Record<string, unknown> | null;
|
|
/** Lifted to the shell so DialogContent's mode-aware className can react —
|
|
* same pattern as ManualCreatePanel. Shared across modes so the user's
|
|
* expand preference persists when switching between agent and manual. */
|
|
isExpanded: boolean;
|
|
setIsExpanded: (v: boolean) => void;
|
|
}) {
|
|
const { t } = useT("modals");
|
|
const workspaceName = useCurrentWorkspace()?.name;
|
|
const wsId = useWorkspaceId();
|
|
const userId = useAuthStore((s) => s.user?.id);
|
|
const { data: members = [] } = useQuery(memberListOptions(wsId));
|
|
const { data: agents = [] } = useQuery(agentListOptions(wsId));
|
|
// Pull `isSuccess` so the stale-id sweep below can distinguish "still
|
|
// loading" from "loaded as empty". Reading length alone treats both as
|
|
// empty and incorrectly clears a valid persisted preference on every open.
|
|
const { data: projects = [], isSuccess: projectsLoaded } = useQuery(
|
|
projectListOptions(wsId),
|
|
);
|
|
|
|
const memberRole = useMemo(
|
|
() => members.find((m) => m.user_id === userId)?.role,
|
|
[members, userId],
|
|
);
|
|
|
|
// Visible = not archived AND assignable by this user.
|
|
const visibleAgents = useMemo(
|
|
() =>
|
|
agents.filter(
|
|
(a) => !a.archived_at && canAssignAgent(a, userId, memberRole),
|
|
),
|
|
[agents, userId, memberRole],
|
|
);
|
|
|
|
const lastAgentId = useQuickCreateStore((s) => s.lastAgentId);
|
|
const setLastAgentId = useQuickCreateStore((s) => s.setLastAgentId);
|
|
const lastProjectId = useQuickCreateStore((s) => s.lastProjectId);
|
|
const setLastProjectId = useQuickCreateStore((s) => s.setLastProjectId);
|
|
const promptDraft = useQuickCreateStore((s) => s.prompt);
|
|
const setPrompt = useQuickCreateStore((s) => s.setPrompt);
|
|
const clearPrompt = useQuickCreateStore((s) => s.clearPrompt);
|
|
const keepOpen = useQuickCreateStore((s) => s.keepOpen);
|
|
const setKeepOpen = useQuickCreateStore((s) => s.setKeepOpen);
|
|
const setLastMode = useCreateModeStore((s) => s.setLastMode);
|
|
|
|
const [agentId, setAgentId] = useState<string | undefined>(() => {
|
|
const seed = (data?.agent_id as string) || lastAgentId || undefined;
|
|
if (seed && visibleAgents.some((a) => a.id === seed)) return seed;
|
|
return visibleAgents[0]?.id;
|
|
});
|
|
|
|
// Re-seed once visible list resolves (queries may be empty on first render).
|
|
useEffect(() => {
|
|
if (agentId && visibleAgents.some((a) => a.id === agentId)) return;
|
|
const seed = (data?.agent_id as string) || lastAgentId || undefined;
|
|
if (seed && visibleAgents.some((a) => a.id === seed)) {
|
|
setAgentId(seed);
|
|
return;
|
|
}
|
|
const first = visibleAgents[0];
|
|
if (first) setAgentId(first.id);
|
|
}, [visibleAgents, agentId, data?.agent_id, lastAgentId]);
|
|
|
|
const selectedAgent = useMemo(
|
|
() => visibleAgents.find((a) => a.id === agentId),
|
|
[visibleAgents, agentId],
|
|
);
|
|
|
|
// Project selection — defaults to the last project the user picked in this
|
|
// workspace. `data?.project_id` lets the modal opener seed a one-shot
|
|
// override (e.g. a future "+ Issue" button on a project page); it does NOT
|
|
// replace the persisted default.
|
|
const [projectId, setProjectId] = useState<string | null>(() => {
|
|
const seed = (data?.project_id as string | undefined) ?? lastProjectId;
|
|
return seed ?? null;
|
|
});
|
|
|
|
// Stale-id sweep. Once the project list query has actually resolved
|
|
// (`isSuccess` — distinct from "data is the empty default during loading"),
|
|
// a `projectId` that isn't in the list means the project was deleted in
|
|
// another session. Clear BOTH local state and the persisted preference;
|
|
// dropping only local state would leave the deleted UUID in `lastProjectId`,
|
|
// and the next open would re-seed it and submit the same dead value.
|
|
useEffect(() => {
|
|
if (!projectsLoaded || projectId === null) return;
|
|
if (projects.some((p) => p.id === projectId)) return;
|
|
setProjectId(null);
|
|
if (lastProjectId === projectId) setLastProjectId(null);
|
|
}, [projectsLoaded, projects, projectId, lastProjectId, setLastProjectId]);
|
|
|
|
// Daemon CLI version gate. The agent-create flow needs the runtime's
|
|
// bundled multica CLI to be ≥ MIN_QUICK_CREATE_CLI_VERSION; older
|
|
// daemons handle attachments and partial-failure retries incorrectly
|
|
// (see PR #1851 / MUL-1496). Pre-check on the picker so the user gets
|
|
// immediate feedback instead of waiting for the inbox failure; the
|
|
// server re-validates as the trust boundary. Dev-built daemons
|
|
// (git-describe shape) are exempted inside checkQuickCreateCliVersion
|
|
// — frontend and server share the same signal there, so they agree by
|
|
// construction across web/desktop/staging without comparing env flags.
|
|
const { data: runtimes = [] } = useQuery(runtimeListOptions(wsId));
|
|
const selectedRuntime = useMemo(
|
|
() =>
|
|
selectedAgent?.runtime_id
|
|
? runtimes.find((r) => r.id === selectedAgent.runtime_id)
|
|
: undefined,
|
|
[runtimes, selectedAgent?.runtime_id],
|
|
);
|
|
const versionCheck = useMemo(
|
|
() => checkQuickCreateCliVersion(readRuntimeCliVersion(selectedRuntime?.metadata)),
|
|
[selectedRuntime?.metadata],
|
|
);
|
|
const versionBlocked = versionCheck.state !== "ok";
|
|
|
|
const initialPrompt = (data?.prompt as string) || promptDraft;
|
|
// 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 [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, uploading } = useFileUpload(api);
|
|
const handleUploadFile = useCallback(
|
|
(file: File) => uploadWithToast(file),
|
|
[uploadWithToast],
|
|
);
|
|
const { isDragOver, dropZoneProps } = useFileDropZone({
|
|
onDrop: (files) => files.forEach((f) => editorRef.current?.uploadFile(f)),
|
|
});
|
|
|
|
useEffect(() => {
|
|
// 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 () => {
|
|
const md = editorRef.current?.getMarkdown()?.trim() ?? "";
|
|
if (!md || !agentId || submitting || versionBlocked || uploading) return;
|
|
setSubmitting(true);
|
|
setError(null);
|
|
try {
|
|
await api.quickCreateIssue({
|
|
agent_id: agentId,
|
|
prompt: md,
|
|
project_id: projectId ?? undefined,
|
|
});
|
|
setLastAgentId(agentId);
|
|
setLastProjectId(projectId);
|
|
clearPrompt();
|
|
setLastMode("agent");
|
|
toast.success(t(($) => $.create_issue.agent.toast_sent), {
|
|
duration: 4000,
|
|
});
|
|
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
|
|
// user can switch to a live agent / upgrade their daemon without
|
|
// leaving the flow.
|
|
if (e instanceof ApiError && e.body && typeof e.body === "object") {
|
|
const body = e.body as {
|
|
code?: string;
|
|
reason?: string;
|
|
current_version?: string;
|
|
min_version?: string;
|
|
};
|
|
if (body.code === "agent_unavailable") {
|
|
setError(body.reason || t(($) => $.create_issue.agent.error_agent_unavailable_fallback));
|
|
setSubmitting(false);
|
|
return;
|
|
}
|
|
if (body.code === "daemon_version_unsupported") {
|
|
// Race fallback: the picker pre-check should normally catch this,
|
|
// but a runtime can silently re-register with an older CLI between
|
|
// pre-check and submit. Same wording as the inline notice for
|
|
// consistency.
|
|
const cur = body.current_version || "unknown";
|
|
setError(
|
|
t(($) => $.create_issue.agent.error_daemon_version, {
|
|
current: cur,
|
|
min: body.min_version || MIN_QUICK_CREATE_CLI_VERSION,
|
|
}),
|
|
);
|
|
setSubmitting(false);
|
|
return;
|
|
}
|
|
}
|
|
setError(t(($) => $.create_issue.agent.error_unknown));
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
// 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: md,
|
|
...(agentId
|
|
? { assigneeType: "agent" as const, assigneeId: agentId }
|
|
: {}),
|
|
});
|
|
setLastMode("manual");
|
|
// Hand the picked project to the manual panel through the same `data`
|
|
// channel that already carries agent_id / parent_issue_id. The manual
|
|
// panel reads `data.project_id` on mount; this preserves the user's
|
|
// selection across the mode flip without piping a third store through.
|
|
onSwitchMode?.(projectId ? { project_id: projectId } : null);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<DialogTitle className="sr-only">{t(($) => $.create_issue.sr_agent)}</DialogTitle>
|
|
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between px-5 pt-3 pb-2 shrink-0">
|
|
<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">{t(($) => $.create_issue.agent_breadcrumb)}</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. Same workaround applies to expand. */}
|
|
<div className="flex items-center gap-1">
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsExpanded(!isExpanded)}
|
|
title={isExpanded ? t(($) => $.common.collapse_tooltip) : t(($) => $.common.expand_tooltip)}
|
|
aria-label={isExpanded ? t(($) => $.common.collapse_tooltip) : t(($) => $.common.expand_tooltip)}
|
|
className="rounded-sm p-1.5 opacity-70 hover:opacity-100 hover:bg-accent/60 transition-all cursor-pointer"
|
|
>
|
|
{isExpanded ? <Minimize2 className="size-4" /> : <Maximize2 className="size-4" />}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
title={t(($) => $.common.close)}
|
|
aria-label={t(($) => $.common.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>
|
|
</div>
|
|
|
|
{/* Agent picker */}
|
|
<div className="px-5 pt-1 pb-2 shrink-0">
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger
|
|
render={
|
|
<button
|
|
type="button"
|
|
aria-label={t(($) => $.create_issue.agent.select_agent_aria)}
|
|
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>{t(($) => $.create_issue.agent.created_by)}</span>
|
|
{selectedAgent ? (
|
|
<span className="flex items-center gap-1.5 text-foreground">
|
|
<ActorAvatar
|
|
actorType="agent"
|
|
actorId={selectedAgent.id}
|
|
size={16}
|
|
/>
|
|
{selectedAgent.name}
|
|
</span>
|
|
) : (
|
|
<span>{t(($) => $.create_issue.agent.pick_an_agent)}</span>
|
|
)}
|
|
</button>
|
|
}
|
|
/>
|
|
<DropdownMenuContent align="start" className="w-64 max-h-72 overflow-y-auto">
|
|
{visibleAgents.length === 0 ? (
|
|
<div className="px-2 py-1.5 text-xs text-muted-foreground">
|
|
{t(($) => $.create_issue.agent.no_agents)}
|
|
</div>
|
|
) : (
|
|
visibleAgents.map((a: Agent) => (
|
|
<DropdownMenuItem
|
|
key={a.id}
|
|
onClick={() => {
|
|
setAgentId(a.id);
|
|
setError(null);
|
|
}}
|
|
className="flex items-center gap-2"
|
|
>
|
|
<ActorAvatar
|
|
actorType="agent"
|
|
actorId={a.id}
|
|
size={16}
|
|
/>
|
|
<span className="flex-1 truncate">{a.name}</span>
|
|
{agentId === a.id && (
|
|
<Check className="size-3.5 text-muted-foreground" />
|
|
)}
|
|
</DropdownMenuItem>
|
|
))
|
|
)}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
|
|
{selectedAgent && versionBlocked && (
|
|
<div className="mx-5 mb-2 shrink-0 rounded-md border border-amber-500/30 bg-amber-500/5 px-3 py-2 text-xs text-amber-700 dark:text-amber-300">
|
|
{versionCheck.state === "missing"
|
|
? t(($) => $.create_issue.agent.version_missing, { min: versionCheck.min })
|
|
: t(($) => $.create_issue.agent.version_below, {
|
|
current: versionCheck.current,
|
|
min: versionCheck.min,
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* 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. */}
|
|
{/* `flex-1 min-h-0 overflow-y-auto` so the editor area absorbs the
|
|
remaining vertical space inside the (now max-bounded) DialogContent
|
|
and scrolls internally. Without it, pasting an image expanded the
|
|
editor unbounded and pushed the modal past the viewport. */}
|
|
<div
|
|
{...dropZoneProps}
|
|
className="relative px-5 pb-3 flex-1 min-h-[140px] overflow-y-auto"
|
|
>
|
|
<ContentEditor
|
|
ref={editorRef}
|
|
defaultValue={initialPrompt}
|
|
placeholder={t(($) => $.create_issue.agent.prompt_placeholder)}
|
|
onUpdate={(md) => {
|
|
setHasContent(md.trim().length > 0);
|
|
setPrompt(md);
|
|
}}
|
|
onUploadFile={handleUploadFile}
|
|
onSubmit={submit}
|
|
debounceMs={150}
|
|
/>
|
|
{isDragOver && <FileDropOverlay />}
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="px-5 pb-2 text-xs text-destructive">{error}</div>
|
|
)}
|
|
|
|
{/* Property toolbar — mirrors the manual panel's pill row so the
|
|
project pill sits in the same place across both modes. Agent mode
|
|
owns only the project (status / priority / assignee / due-date are
|
|
inferred from the prompt), so it's a single pill. The pick is
|
|
persisted per-workspace via useQuickCreateStore.lastProjectId so
|
|
users targeting one project skip retyping "in project X". */}
|
|
<div className="flex items-center gap-1.5 px-4 pb-2 shrink-0 flex-wrap">
|
|
<ProjectPicker
|
|
projectId={projectId}
|
|
onUpdate={(u) => setProjectId(u.project_id ?? null)}
|
|
triggerRender={<PillButton />}
|
|
align="start"
|
|
/>
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<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">
|
|
{t(($) => $.create_issue.agent.sent_count, { count: sentCount })}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex flex-wrap items-center justify-end gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={switchToManual}
|
|
title={t(($) => $.create_issue.switch_to_manual_tooltip)}
|
|
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" />
|
|
{t(($) => $.create_issue.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}
|
|
/>
|
|
{t(($) => $.create_issue.create_another)}
|
|
</label>
|
|
<Button
|
|
size="sm"
|
|
onClick={submit}
|
|
disabled={!hasContent || !agentId || submitting || versionBlocked || uploading}
|
|
title={
|
|
versionBlocked
|
|
? t(($) => $.create_issue.agent.version_blocked_tooltip, { min: versionCheck.min })
|
|
: undefined
|
|
}
|
|
className={justSent ? "min-w-28 !bg-emerald-600 !text-white" : "min-w-28"}
|
|
>
|
|
{submitting ? t(($) => $.create_issue.agent.sending) : uploading ? t(($) => $.create_issue.agent.uploading) : justSent ? (
|
|
<span className="flex items-center gap-1"><Check className="size-3.5" />{t(($) => $.create_issue.agent.sent_label)}</span>
|
|
) : `${t(($) => $.create_issue.agent.submit)} (${formatShortcut(modKey, enterKey)})`}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|