mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-13 11:30:58 +02:00
* fix(ui): replace the text-transparency ladder with solid tones (MUL-5452) Hierarchy was being expressed with transparency: 152 call sites of text-muted-foreground/30..80, 26 of text-foreground/60..90, plus a handful on destructive and current, and a few written as a standalone opacity-* utility instead of a slash alpha. On light surfaces every muted variant failed WCAG AA - /80 reached only 3.78:1 and /40 sat at 1.80:1, below even the 3:1 floor for non-text - because the palette had no step below --muted-foreground, so transparency was the only tool for 'quieter than muted'. The palette now has that step, and it is deliberately non-text: --faint-foreground clears 3:1 (WCAG 1.4.11) on every surface for icons, chevrons, separator glyphs and empty-cell em dashes. There is no room for a third readable text tone - AA caps a lighter text tone 0.018 L away from muted - so text keeps exactly one floor, --muted-foreground. Also fixes text-destructive/70 on a cron error message, which was 3.61:1. This branch changes zero font sizes. The sub-12px half of the issue is MUL-5451's (#6136); keeping the two apart is what makes this one reviewable on its own after #6108 was reverted. apps/web/app/text-contrast.test.ts replaces muted-foreground-contrast.test.ts rather than sitting beside it. It recomputes the floors from tokens.css instead of hard-coding ratios, and fails the build on all four ways to spell the defect: /70, /[0.5], /[50%], and a detached opacity-* in the same class string. Transparency behind hover/focus/disabled stays allowed - the resting state carries the contrast obligation and it is solid. Co-authored-by: multica-agent <github@multica.ai> * fix(ui): correlate transparency across a whole class expression Review found two ways past the guard, both real. A per-literal check cannot see cn("… text-muted-foreground", suppressed && "opacity-60") - one element wearing a colour in one argument and a dim in the next. That split shape is the common one, and it was hiding live violations: the comment trigger chips dimmed aria-pressed label text to 2.55:1 while the sweep reported clean. The second was my own exemption. Accepting any state word within 80 characters let "text-muted-foreground hover:text-foreground opacity-50" through, because the hover: belongs to the colour, not to the opacity. The detector now correlates across a whole cn() call or template literal, splits it into segments that each carry their own condition, and exempts only on the variant prefix the opacity utility itself carries or on the condition governing its segment. Segment splitting is what keeps ${disabled ? "opacity-60" : ""} exempt while flagging its neighbours. Fixed what that surfaced: three trigger-chip controls (the suppressed state is already carried by the avatar's own grayscale, the sentence wording and a solid muted step), a disabled-skill icon chip, and the diff gutter marker. tab-bar's isDragging is exempt - a drag ghost is an in-flight gesture, the same category as :active. The detector now has its own table of thirteen cases. Every hole so far has been silent, so the shapes it must and must not catch are pinned next to the reason each one exists. Co-authored-by: multica-agent <github@multica.ai> * test(ui): cover the faint tone in the cn() merge regression test text-faint-foreground is a new text-<x> class, which is the exact shape that silently broke the sidebar labels in #6108: tailwind-merge cannot tell a size from a colour and drops one of them. The token does resolve correctly today - verified both orders against a size step and against another colour - but the test that exists to catch this was not covering it, so the guarantee rested on nothing. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Lambda <agent@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
948 lines
40 KiB
TypeScript
948 lines
40 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
import {
|
|
ArrowLeftRight,
|
|
CalendarDays,
|
|
Check,
|
|
ChevronRight,
|
|
FolderKanban,
|
|
Maximize2,
|
|
Minimize2,
|
|
MoreHorizontal,
|
|
Settings2,
|
|
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,
|
|
DropdownMenuSeparator,
|
|
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, useWorkspacePaths } from "@multica/core/paths";
|
|
import { useNavigation } from "../navigation";
|
|
import { agentListOptions, squadListOptions } from "@multica/core/workspace/queries";
|
|
import { projectListOptions } from "@multica/core/projects/queries";
|
|
import {
|
|
useQuickCreateStore,
|
|
type QuickCreateActorType,
|
|
} from "@multica/core/issues/stores/quick-create-store";
|
|
import {
|
|
useIssueCreateSettingsStore,
|
|
type QuickCreateField,
|
|
} from "@multica/core/issues/stores/issue-create-settings-store";
|
|
import { useIssueDraftStore, type IssueCreateDraft } from "@multica/core/issues/stores/draft-store";
|
|
import { useCreateModeStore } from "@multica/core/issues/stores/create-mode-store";
|
|
import {
|
|
runtimeListOptions,
|
|
checkQuickCreateCliVersion,
|
|
checkQuickCreateFieldsCliVersion,
|
|
readRuntimeCliVersion,
|
|
} from "@multica/core/runtimes";
|
|
import { useShortcut } from "@multica/core/shortcuts";
|
|
import { ShortcutKeycaps } from "../common/shortcut-keycaps";
|
|
import {
|
|
contentReferencesAttachment,
|
|
type Agent,
|
|
type IssuePriority,
|
|
type Squad,
|
|
} from "@multica/core/types";
|
|
import { ActorAvatar } from "../common/actor-avatar";
|
|
import { PillButton } from "../common/pill-button";
|
|
import { ProjectPicker } from "../projects/components/project-picker";
|
|
import { DueDatePicker, PriorityIcon, PriorityPicker } from "../issues/components";
|
|
import { canAssignAgent } from "../issues/components/pickers/assignee-picker";
|
|
import {
|
|
PropertyPicker,
|
|
PickerItem,
|
|
PickerSection,
|
|
PickerEmpty,
|
|
} from "../issues/components/pickers/property-picker";
|
|
import { useAuthStore } from "@multica/core/auth";
|
|
import { memberListOptions } from "@multica/core/workspace/queries";
|
|
import {
|
|
ContentEditor,
|
|
type ContentEditorRef,
|
|
useFileDropZone,
|
|
FileDropOverlay,
|
|
useUploadGate,
|
|
useComposerSubmit,
|
|
} from "../editor";
|
|
import { useIssueCreateUploads } from "./use-issue-create-uploads";
|
|
import { FileUploadButton } from "@multica/ui/components/common/file-upload-button";
|
|
import { useT } from "../i18n";
|
|
import { matchesPinyin } from "../editor/extensions/pinyin-match";
|
|
|
|
type ActorSelection =
|
|
| { type: "agent"; id: string }
|
|
| { type: "squad"; id: string };
|
|
|
|
// 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 sendShortcut = useShortcut("send");
|
|
const workspaceName = useCurrentWorkspace()?.name;
|
|
const workspacePaths = useWorkspacePaths();
|
|
const navigation = useNavigation();
|
|
const wsId = useWorkspaceId();
|
|
const userId = useAuthStore((s) => s.user?.id);
|
|
const { data: members = [] } = useQuery(memberListOptions(wsId));
|
|
const { data: agents = [] } = useQuery(agentListOptions(wsId));
|
|
const { data: squads = [] } = useQuery(squadListOptions(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. Squads inherit
|
|
// their leader agent's reachability: the backend always routes a squad
|
|
// pick to the leader, so hiding squads whose leader isn't visible keeps
|
|
// the picker honest with what the server would actually accept.
|
|
const visibleAgents = useMemo(
|
|
() =>
|
|
agents.filter(
|
|
(a) => !a.archived_at && canAssignAgent(a, userId, memberRole),
|
|
),
|
|
[agents, userId, memberRole],
|
|
);
|
|
const visibleAgentIds = useMemo(
|
|
() => new Set(visibleAgents.map((a) => a.id)),
|
|
[visibleAgents],
|
|
);
|
|
const visibleSquads = useMemo(
|
|
() =>
|
|
squads.filter(
|
|
(s) => !s.archived_at && visibleAgentIds.has(s.leader_id),
|
|
),
|
|
[squads, visibleAgentIds],
|
|
);
|
|
|
|
const lastActorType = useQuickCreateStore((s) => s.lastActorType);
|
|
const lastActorId = useQuickCreateStore((s) => s.lastActorId);
|
|
const setLastActor = useQuickCreateStore((s) => s.setLastActor);
|
|
const lastProjectId = useQuickCreateStore((s) => s.lastProjectId);
|
|
const setLastProjectId = useQuickCreateStore((s) => s.setLastProjectId);
|
|
const visibleFields = useIssueCreateSettingsStore((s) => s.quickCreateFields);
|
|
const keepOpen = useQuickCreateStore((s) => s.keepOpen);
|
|
const setKeepOpen = useQuickCreateStore((s) => s.setKeepOpen);
|
|
const setLastMode = useCreateModeStore((s) => s.setLastMode);
|
|
// The agent draft (prompt + actor) and the shared fields (project, priority,
|
|
// due date, attachments) live in the unified issue-create draft, so a switch
|
|
// to/from the manual form preserves them.
|
|
const draft = useIssueDraftStore((s) => s.draft);
|
|
const setShared = useIssueDraftStore((s) => s.setShared);
|
|
const setManual = useIssueDraftStore((s) => s.setManual);
|
|
const setAgent = useIssueDraftStore((s) => s.setAgent);
|
|
const setActiveMode = useIssueDraftStore((s) => s.setActiveMode);
|
|
const clearDraft = useIssueDraftStore((s) => s.clearDraft);
|
|
|
|
// Resolve a candidate actor against the currently-visible agents / squads.
|
|
// Returns null when the candidate doesn't exist in this workspace right
|
|
// now (deleted, archived, permission revoked, etc.) so callers can fall
|
|
// through to the next seed in the chain.
|
|
const resolveActor = useCallback(
|
|
(
|
|
type: QuickCreateActorType | "agent" | "squad" | null | undefined,
|
|
id: string | null | undefined,
|
|
): ActorSelection | null => {
|
|
if (!type || !id) return null;
|
|
if (type === "squad" && visibleSquads.some((s) => s.id === id)) {
|
|
return { type: "squad", id };
|
|
}
|
|
if (type === "agent" && visibleAgentIds.has(id)) {
|
|
return { type: "agent", id };
|
|
}
|
|
return null;
|
|
},
|
|
[visibleSquads, visibleAgentIds],
|
|
);
|
|
|
|
const seedActor = useCallback((): ActorSelection | null => {
|
|
// Caller-provided seed wins (e.g. shell pre-seeds with `agent_id` /
|
|
// `squad_id`), then the persisted agent draft, the last successful pick,
|
|
// and finally the first visible agent.
|
|
const dataAgent = data?.agent_id as string | undefined;
|
|
const dataSquad = data?.squad_id as string | undefined;
|
|
return (
|
|
resolveActor("agent", dataAgent) ||
|
|
resolveActor("squad", dataSquad) ||
|
|
resolveActor(draft.agent.actorType, draft.agent.actorId) ||
|
|
resolveActor(lastActorType, lastActorId) ||
|
|
(visibleAgents[0]
|
|
? ({ type: "agent", id: visibleAgents[0].id } as const)
|
|
: null)
|
|
);
|
|
}, [
|
|
resolveActor,
|
|
data?.agent_id,
|
|
data?.squad_id,
|
|
draft.agent.actorType,
|
|
draft.agent.actorId,
|
|
lastActorType,
|
|
lastActorId,
|
|
visibleAgents,
|
|
]);
|
|
|
|
const [actor, setActor] = useState<ActorSelection | null>(() => seedActor());
|
|
|
|
// Re-seed once visible lists resolve (queries may be empty on first render).
|
|
useEffect(() => {
|
|
if (actor && resolveActor(actor.type, actor.id)) return;
|
|
setActor(seedActor());
|
|
}, [actor, resolveActor, seedActor]);
|
|
|
|
const selectedAgent = useMemo<Agent | undefined>(() => {
|
|
if (!actor) return undefined;
|
|
if (actor.type === "agent") return visibleAgents.find((a) => a.id === actor.id);
|
|
const squad = visibleSquads.find((s) => s.id === actor.id);
|
|
if (!squad) return undefined;
|
|
return visibleAgents.find((a) => a.id === squad.leader_id);
|
|
}, [actor, visibleAgents, visibleSquads]);
|
|
|
|
const selectedSquad = useMemo<Squad | undefined>(() => {
|
|
if (actor?.type !== "squad") return undefined;
|
|
return visibleSquads.find((s) => s.id === actor.id);
|
|
}, [actor, visibleSquads]);
|
|
|
|
// Unfinished selections live in the shared issue-create draft. Last-successful
|
|
// actor/project values remain separate fallbacks, so closing a draft never
|
|
// overwrites the defaults established by an actual create.
|
|
const [projectId, setProjectId] = useState<string | null>(() => {
|
|
const seed =
|
|
(data?.project_id as string | undefined) ??
|
|
draft.shared.projectId ??
|
|
lastProjectId;
|
|
return seed ?? null;
|
|
});
|
|
const [priority, setPriority] = useState<IssuePriority>(
|
|
(data?.priority as IssuePriority | undefined) ?? draft.shared.priority,
|
|
);
|
|
const [dueDate, setDueDate] = useState<string | null>(
|
|
(data?.due_date as string | undefined) ?? draft.shared.dueDate,
|
|
);
|
|
const [fieldPickerOpen, setFieldPickerOpen] = useState<QuickCreateField | null>(null);
|
|
|
|
// Parent-issue context — seeded by `openCreateSubIssue` when the modal is
|
|
// opened from the "Add sub issue" entry on an existing issue. We carry it
|
|
// through (not as an editable form field) so a manual→agent flip preserves
|
|
// the sub-issue intent; the agent panel never exposes this as a picker.
|
|
// Identifier is best-effort display context only — the UUID is the
|
|
// authoritative reference the backend/agent uses for `--parent <uuid>`.
|
|
const parentIssueId = (data?.parent_issue_id as string | undefined) ?? undefined;
|
|
const parentIssueIdentifier =
|
|
(data?.parent_issue_identifier as string | undefined) ?? undefined;
|
|
|
|
// 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 local state, the unfinished draft, and the
|
|
// last-successful preference; dropping any persisted copy would make the
|
|
// next open re-seed and submit the same dead value.
|
|
useEffect(() => {
|
|
if (!projectsLoaded || projectId === null) return;
|
|
if (projects.some((p) => p.id === projectId)) return;
|
|
setProjectId(null);
|
|
if (draft.shared.projectId === projectId) {
|
|
setShared({ projectId: undefined });
|
|
}
|
|
if (lastProjectId === projectId) setLastProjectId(null);
|
|
}, [
|
|
projectsLoaded,
|
|
projects,
|
|
projectId,
|
|
draft.shared.projectId,
|
|
lastProjectId,
|
|
setShared,
|
|
setLastProjectId,
|
|
]);
|
|
|
|
// Mark the persisted draft's active mode so a later reopen and any reader of
|
|
// the unified draft know which form is being edited.
|
|
useEffect(() => {
|
|
setActiveMode("agent");
|
|
}, [setActiveMode]);
|
|
|
|
// 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 runtimeCliVersion = readRuntimeCliVersion(selectedRuntime?.metadata);
|
|
const baseVersionCheck = useMemo(
|
|
() => checkQuickCreateCliVersion(runtimeCliVersion),
|
|
[runtimeCliVersion],
|
|
);
|
|
const fieldVersionCheck = useMemo(
|
|
() => checkQuickCreateFieldsCliVersion(runtimeCliVersion),
|
|
[runtimeCliVersion],
|
|
);
|
|
const usesExplicitFields = priority !== "none" || dueDate !== null;
|
|
const versionCheck = usesExplicitFields ? fieldVersionCheck : baseVersionCheck;
|
|
const versionBlocked =
|
|
baseVersionCheck.state !== "ok" ||
|
|
(usesExplicitFields && fieldVersionCheck.state !== "ok");
|
|
|
|
const initialPrompt = draft.agent.prompt || (data?.prompt as string) || "";
|
|
// 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 [justSent, setJustSent] = useState(false);
|
|
const [sentCount, setSentCount] = useState(0);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const uploadGate = useUploadGate(editorRef);
|
|
// Coordinator-owned uploads in the shared draft pool (MUL-5181, L2): a file
|
|
// pasted into the prompt survives dialog close and mode switches, aborts on
|
|
// logout, and is dropped after a reload. `gate` widens the editor gate with
|
|
// the pool's placeholders.
|
|
const {
|
|
attachments: pendingAttachments,
|
|
handleUpload: handleUploadFile,
|
|
gate,
|
|
} = useIssueCreateUploads("agent", uploadGate, editorRef);
|
|
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);
|
|
}, []);
|
|
|
|
// Agent create runs through the shared await-then-render composer contract
|
|
// (single-flight ref, submit-time upload re-check, lock+spin, await→boolean,
|
|
// clear only on acceptance). The prompt IS the editor content, so this maps
|
|
// onto the hook directly.
|
|
// Stale-submit guard (MUL-5181 P0): the issue draft is a SINGLETON store.
|
|
// A late success from a dialog the user closed mid-submit must not clear a
|
|
// newer draft typed after reopening — see ManualCreatePanel for the rule.
|
|
const mountedRef = useRef(true);
|
|
useLayoutEffect(() => {
|
|
mountedRef.current = true;
|
|
return () => {
|
|
mountedRef.current = false;
|
|
};
|
|
}, []);
|
|
const submittedDraftRef = useRef<IssueCreateDraft | null>(null);
|
|
// Set by `onAccepted` only on the branch that keeps the panel open and wipes
|
|
// the editor; read back by `afterAccepted`. The closing branch must not
|
|
// refocus an editor that is about to unmount with the dialog.
|
|
const refocusAfterAcceptRef = useRef(false);
|
|
|
|
const composer = useComposerSubmit({
|
|
editorRef,
|
|
uploadGate: gate,
|
|
onSubmit: async (md): Promise<boolean> => {
|
|
// The button already disables on !actor / versionBlocked, but the
|
|
// ⌘+Enter path bypasses it — re-guard here and keep the draft in place.
|
|
if (!actor || versionBlocked) return false;
|
|
// Flush the prompt editor's pending debounce before snapshotting — see
|
|
// ManualCreatePanel.
|
|
const pendingPrompt = editorRef.current?.flushPendingUpdate?.();
|
|
if (pendingPrompt != null) setAgent({ prompt: pendingPrompt });
|
|
submittedDraftRef.current = useIssueDraftStore.getState().draft;
|
|
const activeAttachmentIds = pendingAttachments
|
|
.filter((a) => contentReferencesAttachment(md, a))
|
|
.map((a) => a.id);
|
|
setError(null);
|
|
try {
|
|
await api.quickCreateIssue({
|
|
...(actor.type === "agent"
|
|
? { agent_id: actor.id }
|
|
: { squad_id: actor.id }),
|
|
prompt: md,
|
|
project_id: projectId ?? undefined,
|
|
...(priority !== "none" ? { priority } : {}),
|
|
...(dueDate ? { due_date: dueDate } : {}),
|
|
parent_issue_id: parentIssueId,
|
|
...(activeAttachmentIds.length > 0 ? { attachment_ids: activeAttachmentIds } : {}),
|
|
});
|
|
setLastActor(actor.type, actor.id);
|
|
setLastProjectId(projectId);
|
|
setLastMode("agent");
|
|
toast.success(t(($) => $.create_issue.agent.toast_sent), {
|
|
duration: 4000,
|
|
});
|
|
return true;
|
|
} 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));
|
|
return false;
|
|
}
|
|
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 || versionCheck.min,
|
|
}),
|
|
);
|
|
return false;
|
|
}
|
|
}
|
|
setError(
|
|
e instanceof Error && e.message
|
|
? e.message
|
|
: t(($) => $.create_issue.agent.error_unknown),
|
|
);
|
|
return false;
|
|
}
|
|
},
|
|
// Continuous-creation mode puts the caret back so the next prompt can be
|
|
// typed immediately. Deliberately no `containerRef`: this is a dialog whose
|
|
// own focus trap already bounds where focus can be, and reclaiming it is the
|
|
// point of keep-open mode.
|
|
afterAccepted: () => (refocusAfterAcceptRef.current ? "refocus" : "none"),
|
|
onAccepted: () => {
|
|
refocusAfterAcceptRef.current = false;
|
|
// A successful create ends this whole draft (shared + manual + agent);
|
|
// last-successful actor/project preferences were saved in onSubmit.
|
|
// Success may only consume the draft it submitted: flush the editor's
|
|
// pending debounce first, then clear only an untouched draft — edits
|
|
// made mid-flight or by a reopened dialog survive.
|
|
const latePrompt = editorRef.current?.flushPendingUpdate?.();
|
|
if (latePrompt != null) setAgent({ prompt: latePrompt });
|
|
const untouched =
|
|
useIssueDraftStore.getState().draft === submittedDraftRef.current;
|
|
if (untouched) clearDraft();
|
|
// An edit made during the request survives; the panel stays open on it.
|
|
if (!mountedRef.current || !untouched) return;
|
|
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);
|
|
refocusAfterAcceptRef.current = true;
|
|
} else {
|
|
onClose();
|
|
}
|
|
},
|
|
});
|
|
const submit = () => {
|
|
void composer.submit();
|
|
};
|
|
const submitting = composer.submitting;
|
|
|
|
// Switch to the manual form WITHOUT destroying the agent draft. The agent
|
|
// slot (prompt + actor) is left untouched so a later manual→agent flip
|
|
// restores it verbatim. Project / priority / due date already live in the
|
|
// shared slot and carry across for free. Two one-time assist-inits run only
|
|
// when the manual slot is still empty: seed the description from the prompt
|
|
// and the assignee from the picked actor. The parent-issue context is not
|
|
// persisted (a per-invocation intent) so it rides the carry channel.
|
|
const switchToManual = () => {
|
|
// The prompt is copied into the manual description on assist-init; mid-upload
|
|
// that body has already lost the pending image (see switchToAgent).
|
|
if (gate.isBlocked()) return;
|
|
// Commit the shared fields to the draft so the manual panel reads them from
|
|
// there — local state can hold a value seeded from `data` that was never
|
|
// written through a picker.
|
|
setShared({ projectId: projectId ?? undefined, priority, dueDate });
|
|
if (!draft.manual.description.trim()) {
|
|
const md = editorRef.current?.getMarkdown() ?? "";
|
|
if (md) setManual({ description: md });
|
|
}
|
|
if (!draft.manual.assigneeId && actor) {
|
|
setManual({ assigneeType: actor.type, assigneeId: actor.id });
|
|
}
|
|
setLastMode("manual");
|
|
setActiveMode("manual");
|
|
const carry: Record<string, unknown> = {};
|
|
if (parentIssueId) carry.parent_issue_id = parentIssueId;
|
|
if (parentIssueIdentifier) carry.parent_issue_identifier = parentIssueIdentifier;
|
|
onSwitchMode?.(Object.keys(carry).length > 0 ? carry : null);
|
|
};
|
|
|
|
// Field visibility lives in Settings → Issue. Persist the prompt draft
|
|
// before leaving so what the user typed survives the round-trip, then
|
|
// close — the dialog would otherwise linger over the settings page.
|
|
const openFieldSettings = () => {
|
|
setAgent({ prompt: editorRef.current?.getMarkdown() ?? "" });
|
|
onClose();
|
|
navigation.push(`${workspacePaths.settings()}?tab=issue`);
|
|
};
|
|
|
|
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-caption">
|
|
<span className="text-muted-foreground">{workspaceName}</span>
|
|
<ChevronRight className="size-3 text-faint-foreground" />
|
|
<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>
|
|
|
|
{/* Actor picker — agents and squads in one searchable list. Squads
|
|
route to their leader agent on the backend; the leader runs the
|
|
quick-create flow with the squad's Operating Protocol layered
|
|
on top, so a squad pick is "ask this squad to file the issue". */}
|
|
<div className="px-5 pt-1 pb-2 shrink-0">
|
|
<ActorPicker
|
|
actor={actor}
|
|
visibleAgents={visibleAgents}
|
|
visibleSquads={visibleSquads}
|
|
selectedAgent={selectedAgent}
|
|
selectedSquad={selectedSquad}
|
|
onPick={(next) => {
|
|
setActor(next);
|
|
setAgent({ actorType: next.type, actorId: next.id });
|
|
setError(null);
|
|
}}
|
|
t={t}
|
|
/>
|
|
</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-caption 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);
|
|
setAgent({ prompt: md });
|
|
}}
|
|
onUploadFile={handleUploadFile}
|
|
onUploadingChange={uploadGate.onUploadingChange}
|
|
attachments={pendingAttachments}
|
|
onSubmit={submit}
|
|
debounceMs={150}
|
|
/>
|
|
{isDragOver && <FileDropOverlay />}
|
|
</div>
|
|
|
|
|
|
{error && (
|
|
<div className="px-5 pb-2 text-caption text-destructive">{error}</div>
|
|
)}
|
|
|
|
{/* Property toolbar — the project is visible by default; priority and
|
|
due date live behind the overflow until exposed in settings or
|
|
given a value. Unfinished picks remain workspace-persistent.
|
|
When the modal was opened from "Add sub issue" on an existing
|
|
issue, a read-only chip on the same row tells the user that the
|
|
new issue will be filed as a sub-issue of that parent — the agent
|
|
handles the wiring silently, but surfacing the relationship
|
|
avoids "where did this end up?" surprise. We deliberately keep
|
|
it non-editable: changing the parent is a `Set parent` action on
|
|
the parent itself, not a knob in the quick-create flow. */}
|
|
<div className="flex items-center gap-1.5 px-4 pb-2 shrink-0 flex-wrap">
|
|
{(visibleFields.includes("project") ||
|
|
projectId !== null ||
|
|
fieldPickerOpen === "project") && (
|
|
<ProjectPicker
|
|
projectId={projectId}
|
|
onUpdate={(u) => {
|
|
const next = u.project_id ?? null;
|
|
setProjectId(next);
|
|
setShared({ projectId: next ?? undefined });
|
|
}}
|
|
triggerRender={<PillButton />}
|
|
align="start"
|
|
open={fieldPickerOpen === "project" ? true : undefined}
|
|
onOpenChange={(open) => setFieldPickerOpen(open ? "project" : null)}
|
|
/>
|
|
)}
|
|
{(visibleFields.includes("priority") ||
|
|
priority !== "none" ||
|
|
fieldPickerOpen === "priority") && (
|
|
<PriorityPicker
|
|
priority={priority}
|
|
onUpdate={(updates) => {
|
|
if (updates.priority) {
|
|
setPriority(updates.priority);
|
|
setShared({ priority: updates.priority });
|
|
}
|
|
}}
|
|
triggerRender={<PillButton />}
|
|
align="start"
|
|
open={fieldPickerOpen === "priority" ? true : undefined}
|
|
onOpenChange={(open) => setFieldPickerOpen(open ? "priority" : null)}
|
|
/>
|
|
)}
|
|
{(visibleFields.includes("due_date") ||
|
|
dueDate !== null ||
|
|
fieldPickerOpen === "due_date") && (
|
|
<DueDatePicker
|
|
dueDate={dueDate}
|
|
onUpdate={(updates) => {
|
|
const next = updates.due_date ?? null;
|
|
setDueDate(next);
|
|
setShared({ dueDate: next });
|
|
}}
|
|
triggerRender={<PillButton />}
|
|
align="start"
|
|
open={fieldPickerOpen === "due_date" ? true : undefined}
|
|
onOpenChange={(open) => setFieldPickerOpen(open ? "due_date" : null)}
|
|
/>
|
|
)}
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger
|
|
render={
|
|
<PillButton
|
|
aria-label={t(($) => $.create_issue.agent.more_fields_aria)}
|
|
title={t(($) => $.create_issue.agent.more_fields_aria)}
|
|
>
|
|
<MoreHorizontal className="size-3.5" />
|
|
</PillButton>
|
|
}
|
|
/>
|
|
<DropdownMenuContent align="start" className="w-52">
|
|
{!visibleFields.includes("project") && projectId === null && (
|
|
<DropdownMenuItem onClick={() => setFieldPickerOpen("project")}>
|
|
<FolderKanban className="size-3.5 text-muted-foreground" />
|
|
{t(($) => $.create_issue.agent.set_project)}
|
|
</DropdownMenuItem>
|
|
)}
|
|
{!visibleFields.includes("priority") && priority === "none" && (
|
|
<DropdownMenuItem onClick={() => setFieldPickerOpen("priority")}>
|
|
<PriorityIcon priority="none" className="size-3.5" />
|
|
{t(($) => $.create_issue.agent.set_priority)}
|
|
</DropdownMenuItem>
|
|
)}
|
|
{!visibleFields.includes("due_date") && dueDate === null && (
|
|
<DropdownMenuItem onClick={() => setFieldPickerOpen("due_date")}>
|
|
<CalendarDays className="size-3.5 text-muted-foreground" />
|
|
{t(($) => $.create_issue.agent.set_due_date)}
|
|
</DropdownMenuItem>
|
|
)}
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem onClick={openFieldSettings}>
|
|
<Settings2 className="size-3.5 text-muted-foreground" />
|
|
{t(($) => $.create_issue.agent.customize_fields)}
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
{parentIssueId && (
|
|
<span
|
|
data-testid="agent-sub-issue-chip"
|
|
className="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-caption text-muted-foreground"
|
|
title={t(($) => $.create_issue.agent.sub_issue_of, {
|
|
identifier: parentIssueIdentifier ?? "",
|
|
})}
|
|
>
|
|
{t(($) => $.create_issue.agent.sub_issue_of, {
|
|
identifier: parentIssueIdentifier ?? "",
|
|
})}
|
|
</span>
|
|
)}
|
|
</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">
|
|
{/* Deliberately NOT disabled while uploading: each file is its
|
|
own queue entry, so queueing a second one is safe and waiting
|
|
for the first to land just to attach the next is busywork. */}
|
|
<FileUploadButton
|
|
size="sm"
|
|
multiple
|
|
onSelect={(file) => editorRef.current?.uploadFile(file)}
|
|
/>
|
|
{keepOpen && sentCount > 0 && (
|
|
<span className="text-caption 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}
|
|
disabled={gate.uploading}
|
|
aria-disabled={gate.uploading || undefined}
|
|
aria-busy={gate.uploading || undefined}
|
|
title={t(($) => $.create_issue.switch_to_manual_tooltip)}
|
|
className="flex shrink-0 items-center gap-1.5 text-caption px-2 py-1 rounded-sm text-muted-foreground hover:text-foreground hover:bg-accent/60 transition-colors cursor-pointer disabled:cursor-not-allowed disabled:opacity-50"
|
|
>
|
|
<ArrowLeftRight className="size-3.5" />
|
|
{t(($) => $.create_issue.switch_to_manual)}
|
|
</button>
|
|
<label className="flex shrink-0 items-center gap-1.5 text-caption 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 || !actor || submitting || versionBlocked || gate.uploading}
|
|
aria-disabled={gate.uploading || undefined}
|
|
// Sending is a busy state too, not just uploading.
|
|
aria-busy={gate.uploading || submitting || undefined}
|
|
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) : gate.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)}
|
|
{sendShortcut ? (
|
|
<ShortcutKeycaps
|
|
shortcut={sendShortcut}
|
|
decorative
|
|
className="ml-1"
|
|
keyClassName="border-background/30 bg-background/15 text-primary-foreground shadow-none"
|
|
/>
|
|
) : null}
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
// ActorPicker — the "Created by" trigger + searchable popover listing
|
|
// agents and squads. Lives in this file (not under issues/components/pickers)
|
|
// because it composes the generic PropertyPicker with a quick-create-shaped
|
|
// trigger styled to match the modal header row — promoting it would invite
|
|
// reuse pressure on a UI that's deliberately tuned for this one surface.
|
|
function ActorPicker({
|
|
actor,
|
|
visibleAgents,
|
|
visibleSquads,
|
|
selectedAgent,
|
|
selectedSquad,
|
|
onPick,
|
|
t,
|
|
}: {
|
|
actor: ActorSelection | null;
|
|
visibleAgents: Agent[];
|
|
visibleSquads: Squad[];
|
|
selectedAgent: Agent | undefined;
|
|
selectedSquad: Squad | undefined;
|
|
onPick: (next: ActorSelection) => void;
|
|
t: ReturnType<typeof useT<"modals">>["t"];
|
|
}) {
|
|
const [open, setOpen] = useState(false);
|
|
const [filter, setFilter] = useState("");
|
|
const query = filter.trim().toLowerCase();
|
|
|
|
const filteredAgents = useMemo(
|
|
() => visibleAgents.filter((a) => a.name.toLowerCase().includes(query) || matchesPinyin(a.name, query)),
|
|
[visibleAgents, query],
|
|
);
|
|
const filteredSquads = useMemo(
|
|
() => visibleSquads.filter((s) => s.name.toLowerCase().includes(query) || matchesPinyin(s.name, query)),
|
|
[visibleSquads, query],
|
|
);
|
|
|
|
const displayLabel = selectedSquad?.name ?? selectedAgent?.name;
|
|
const displayActor: ActorSelection | null = selectedSquad
|
|
? { type: "squad", id: selectedSquad.id }
|
|
: selectedAgent
|
|
? { type: "agent", id: selectedAgent.id }
|
|
: null;
|
|
|
|
return (
|
|
<PropertyPicker
|
|
open={open}
|
|
onOpenChange={(v: boolean) => {
|
|
setOpen(v);
|
|
if (!v) setFilter("");
|
|
}}
|
|
width="w-64"
|
|
align="start"
|
|
searchable
|
|
searchPlaceholder={t(($) => $.create_issue.agent.search_placeholder)}
|
|
onSearchChange={setFilter}
|
|
trigger={
|
|
<span className="flex items-center gap-2 text-caption text-muted-foreground hover:text-foreground transition-colors">
|
|
<span>{t(($) => $.create_issue.agent.created_by)}</span>
|
|
{displayActor && displayLabel ? (
|
|
<span className="flex items-center gap-1.5 text-foreground">
|
|
<ActorAvatar
|
|
actorType={displayActor.type}
|
|
actorId={displayActor.id}
|
|
size="sm"
|
|
/>
|
|
{displayLabel}
|
|
</span>
|
|
) : (
|
|
<span>{t(($) => $.create_issue.agent.pick_an_agent)}</span>
|
|
)}
|
|
</span>
|
|
}
|
|
>
|
|
{filteredAgents.length === 0 && filteredSquads.length === 0 ? (
|
|
query ? (
|
|
<PickerEmpty />
|
|
) : (
|
|
<div className="px-2 py-1.5 text-caption text-muted-foreground">
|
|
{t(($) => $.create_issue.agent.no_agents)}
|
|
</div>
|
|
)
|
|
) : (
|
|
<>
|
|
{filteredAgents.length > 0 && (
|
|
<PickerSection label={t(($) => $.create_issue.agent.agents_group)}>
|
|
{filteredAgents.map((a) => (
|
|
<PickerItem
|
|
key={a.id}
|
|
selected={actor?.type === "agent" && actor.id === a.id}
|
|
onClick={() => {
|
|
onPick({ type: "agent", id: a.id });
|
|
setOpen(false);
|
|
}}
|
|
>
|
|
<ActorAvatar actorType="agent" actorId={a.id} size="sm" />
|
|
<span className="truncate">{a.name}</span>
|
|
</PickerItem>
|
|
))}
|
|
</PickerSection>
|
|
)}
|
|
{filteredSquads.length > 0 && (
|
|
<PickerSection label={t(($) => $.create_issue.agent.squads_group)}>
|
|
{filteredSquads.map((s) => (
|
|
<PickerItem
|
|
key={s.id}
|
|
selected={actor?.type === "squad" && actor.id === s.id}
|
|
onClick={() => {
|
|
onPick({ type: "squad", id: s.id });
|
|
setOpen(false);
|
|
}}
|
|
>
|
|
<ActorAvatar actorType="squad" actorId={s.id} size="sm" />
|
|
<span className="truncate">{s.name}</span>
|
|
</PickerItem>
|
|
))}
|
|
</PickerSection>
|
|
)}
|
|
</>
|
|
)}
|
|
</PropertyPicker>
|
|
);
|
|
}
|