mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-12 19:06:06 +02:00
* feat(composer): post-send caret policy per surface (afterAccepted)
Where the caret goes after a send was hand-rolled per composer: chat blurred,
quick-create hand-wrote its own requestAnimationFrame focus, and comment/reply
did nothing at all. The mechanics are identical everywhere and easy to get
wrong (must run after the clear, must survive a dialog focus trap, must not
steal focus the user moved elsewhere mid-flight), so they now live once in the
shared send contract.
`useComposerSubmit` gains `afterAccepted` ("refocus" | "blur" | "none",
default "none") plus an optional `containerRef` that bounds focus reclaim to
the composer that sent. The mode may be a function so a surface can decide at
accept time — chat only reclaims focus when the commit actually scrubbed the
shared editor, never when a fire-and-forget send left another session's draft
on screen.
Per-surface policy:
- Chat refocuses (one file, three mount points: chat page, floating window,
agent creation studio). Replaces the deliberate blur.
- Thread replies refocus — the user is mid-conversation.
- A top-level comment blurs, and IssueDetail reveals what was posted instead:
submitComment now returns the created id, and the page scrolls to that row
and flashes it with the same jumpToThread the inbox deep-link uses.
- Quick create's keep-open mode drops its hand-written rAF for the option.
- Inline comment edit and Create Issue keep "none": both close on save.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor(composer): drop the scroll-to-posted-comment reveal
The top-level composer is sticky at the bottom of the timeline, so a comment
posted from it lands directly above the box and is almost always already on
screen — the scroll was a no-op and the flash re-announced something the user
had just deliberately done. The flash earns its keep for inbox deep-links,
where the user did not choose the landing spot.
`submitComment` goes back to returning a boolean; it only carried the created
id to feed the reveal. The composer still blurs after posting: the turn is
over, so the caret is dropped rather than kept.
The shared contract is untouched — `afterAccepted` never knew about comments,
which is why removing this costs nothing outside IssueDetail.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs(composer): drop stale references to the removed comment reveal
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(composer): bind the post-send caret policy to an actually-cleared editor
Review blocker: CommentInput passed a literal `afterAccepted: "blur"`, but its
stale-submit guard declines to clear when the user typed during the request.
Posting comment A on a slow connection while typing comment B therefore
dropped the caret out of B mid-sentence — the guard kept the text, and the
blur fired anyway.
Both issue composers now resolve the mode from a ref set only on the branch
that really wipes the editor, matching what ChatInput and quick-create already
do. ReplyInput gets the same treatment: refocusing a box the user is typing in
happens to be harmless, but leaving one surface on the unsafe shape invites the
next one to copy it.
The rule is now stated on the option itself: a surface whose `onAccepted` can
decline to clear must pass a function and resolve to "none" on those paths.
Both regressions are mutation-verified — reverting either binding fails the new
assertions.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
958 lines
40 KiB
TypeScript
958 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 { ComposerUploadChips } from "../issues/components/composer-upload-chips";
|
|
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 reads `interrupted` after a reload. `gate` widens the editor
|
|
// gate with the pool's placeholders.
|
|
const {
|
|
uploads: draftUploads,
|
|
attachments: pendingAttachments,
|
|
handleUpload: handleUploadFile,
|
|
removeUpload,
|
|
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-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>
|
|
|
|
{/* 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-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);
|
|
setAgent({ prompt: md });
|
|
}}
|
|
onUploadFile={handleUploadFile}
|
|
onUploadingChange={uploadGate.onUploadingChange}
|
|
attachments={pendingAttachments}
|
|
onSubmit={submit}
|
|
debounceMs={150}
|
|
/>
|
|
{isDragOver && <FileDropOverlay />}
|
|
</div>
|
|
|
|
{draftUploads.some((u) => u.status !== "uploaded") && (
|
|
<ComposerUploadChips
|
|
uploads={draftUploads}
|
|
onRemove={removeUpload}
|
|
className="px-5 pb-1"
|
|
/>
|
|
)}
|
|
|
|
{error && (
|
|
<div className="px-5 pb-2 text-xs 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-xs 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-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}
|
|
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-xs 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-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 || !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-xs 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-xs 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>
|
|
);
|
|
}
|