mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-30 16:20:35 +02:00
* fix(modals): correct text input height in issue creation dialog Fixed text input height for both agent and manual create issue dialogs: - Agent dialog: added flex to outer div and flex-1 to inner div - Manual dialog: added flex to description container and flex-1 to editor Fixed: #2433 * fix(editor): make EditorContent a proper flex container - EditorContent: flex flex-1 flex-col - Remove min-height: 100% from .ProseMirror CSS - Let flex-grow handle height consistently across the chain Fixed: #2433 --------- Co-authored-by: ayakabot <ayakabot@seepine.com>
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 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>
|
|
</>
|
|
);
|
|
}
|