mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-27 21:33:41 +02:00
* feat(chat): add project context Co-authored-by: multica-agent <github@multica.ai> * fix(chat): resolve MUL-5150 review blockers - Renumber project-context migrations to unique prefixes after current main: 206_chat_session_project -> 212 (column), 207_chat_session_project_index -> 213 (concurrent index). 206/207 collided with 206_agent_disabled_runtime_skills and main's 207-211 client_usage_daily set. - Add the 4 missing chat input.project_context keys to ja/ko locales so the locale parity test passes (en/zh-Hans already had them). - Lock the project-context control while a send is in flight (isSubmitting), not just while the agent is running. A brand-new chat creates its session lazily during send bound to the project at click time; switching project mid-send would create the session against the stale project and clear the editor as if the send landed on the new selection. Add a regression test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * fix(chat): complete project context handling * fix(chat): pin fresh chat to open session's agent on project switch Switching an existing session to a different project opens a fresh chat but only cleared the active session, dropping selection back to the stored `selectedAgentId`. When that preference was stale (open session belongs to agent B while the persisted pick is still agent A), the lazily-created session and its first send bound to the wrong agent (agent A). Extract the project-switch decision into a shared `planProjectContextChange` pure helper in use-chat-controller.ts and route both chat surfaces (the chat tab controller and the floating ChatWindow) through it, so the fresh chat is pinned to the open session's agent and the rule cannot drift between the two copies. Add a dual-entry regression test (pure-fn guard + controller integration) covering the stale selectedAgentId case. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * chore(ci): re-trigger required checks on latest head The prior push updated the branch ref but GitHub did not emit a pull_request synchronize for it (PR head-sync lag), so CI/Mobile Verify never ran on the commit carrying the stale-agent project-switch fix. Empty commit to force a fresh synchronize on a head that includes it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * fix(chat): renumber project migrations to 213/214 after main added 212 Current main added 212_agent_service_tier; the PR's 212/213 chat migrations collided with it on the merge ref, failing TestMigrationNumericPrefixesStay UniqueAfterLegacySet. Merge current main and move the chat column migration to 213 and the concurrent index migration to 214 (column before index preserved). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * fix(chat): lock ProjectPicker clear control during send (keyboard path) The send-pending lock only put pointer-events-none on the wrapper, which blocks the mouse but leaves ProjectPicker's inline clear button in the tab order — a keyboard user could Tab to "Remove from project" and press Enter mid-send, detaching the project after the lazily-created session already went out with the old one (reopens the mid-send retarget path via keyboard). Add an explicit `disabled` capability to the shared ProjectPicker that locks the trigger, the menu (forced closed), and the inline clear button (disabled + out of the tab order). Defaults to false, so issue/create/autopilot callers keep their hover/keyboard clear. ChatInput passes disabled while the project selection is locked. Tests: real-ProjectPicker regression (keyboard activation of the clear control is inert when disabled; still works when enabled) + ChatInput wiring assertion that the picker is disabled mid-send. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Lambda <lambda@multica.ai> Co-authored-by: multica-agent <github@multica.ai> Co-authored-by: Walt <walt@multica.ai> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Co-authored-by: NevilleQingNY <nevilleqing@gmail.com>
121 lines
5.2 KiB
TypeScript
121 lines
5.2 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { Check, FolderKanban, X } from "lucide-react";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { projectListOptions } from "@multica/core/projects/queries";
|
|
import { useWorkspaceId } from "@multica/core/hooks";
|
|
import type { UpdateIssueRequest } from "@multica/core/types";
|
|
import { cn } from "@multica/ui/lib/utils";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
DropdownMenuSeparator,
|
|
} from "@multica/ui/components/ui/dropdown-menu";
|
|
import { ProjectIcon } from "./project-icon";
|
|
import { useT } from "../../i18n";
|
|
|
|
export function ProjectPicker({
|
|
projectId,
|
|
onUpdate,
|
|
triggerRender,
|
|
align = "start",
|
|
defaultOpen = false,
|
|
open: controlledOpen,
|
|
onOpenChange,
|
|
disabled = false,
|
|
}: {
|
|
projectId: string | null;
|
|
onUpdate: (updates: Partial<UpdateIssueRequest>) => void;
|
|
triggerRender?: React.ReactElement;
|
|
align?: "start" | "center" | "end";
|
|
/** Open the dropdown on first mount. Used by progressive-disclosure
|
|
* sidebars so a newly-added field immediately enters edit state. */
|
|
defaultOpen?: boolean;
|
|
open?: boolean;
|
|
onOpenChange?: (open: boolean) => void;
|
|
/** Read-only lock. When true the trigger, the menu, and the inline clear
|
|
* button are all disabled and out of the tab order, so no project-context
|
|
* mutation can fire — pointer OR keyboard. Callers that must freeze the
|
|
* selection during a transient window (an in-flight chat send) pass this;
|
|
* every other caller keeps the default hover/keyboard clear behavior since
|
|
* it defaults to false. */
|
|
disabled?: boolean;
|
|
}) {
|
|
const { t } = useT("projects");
|
|
const wsId = useWorkspaceId();
|
|
const { data: projects = [] } = useQuery(projectListOptions(wsId));
|
|
const current = projects.find((p) => p.id === projectId);
|
|
// Normalize to an always-boolean controlled `open`, matching the other
|
|
// pickers (status/priority/assignee/labels). Base UI's Menu latches a
|
|
// controlled `open={true}` — a later `undefined` does NOT close it — so
|
|
// callers wiring `open={cond ? true : undefined}` (create-issue dialog)
|
|
// would leave the popup stuck open after selecting a project.
|
|
const [internalOpen, setInternalOpen] = useState(defaultOpen);
|
|
// A disabled picker can never be open, and no interaction may reopen it.
|
|
const open = disabled ? false : controlledOpen ?? internalOpen;
|
|
const setOpen = disabled ? () => {} : onOpenChange ?? setInternalOpen;
|
|
|
|
return (
|
|
<DropdownMenu open={open} onOpenChange={setOpen}>
|
|
<div className="group/project relative inline-flex min-w-0">
|
|
<DropdownMenuTrigger
|
|
disabled={disabled}
|
|
className={
|
|
triggerRender
|
|
? undefined
|
|
: cn(
|
|
"flex items-center gap-1.5 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors overflow-hidden",
|
|
current && "pr-5",
|
|
)
|
|
}
|
|
render={triggerRender}
|
|
>
|
|
{current ? (
|
|
<ProjectIcon project={current} size="sm" />
|
|
) : (
|
|
<FolderKanban className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
)}
|
|
<span className="truncate">{current ? current.title : t(($) => $.picker.no_project)}</span>
|
|
</DropdownMenuTrigger>
|
|
{current && (
|
|
<button
|
|
type="button"
|
|
disabled={disabled}
|
|
aria-label={t(($) => $.picker.remove)}
|
|
onClick={(event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
onUpdate({ project_id: null });
|
|
}}
|
|
className="pointer-events-none absolute right-1 top-1/2 flex size-3.5 -translate-y-1/2 items-center justify-center rounded-sm text-muted-foreground opacity-0 transition-[background-color,color,opacity] hover:bg-muted-foreground/20 hover:text-foreground focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-none group-hover/project:pointer-events-auto group-hover/project:opacity-100 focus-visible:pointer-events-auto focus-visible:opacity-100 disabled:pointer-events-none disabled:opacity-0 disabled:group-hover/project:opacity-0"
|
|
>
|
|
<X className="size-2.5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
<DropdownMenuContent align={align} className="w-52">
|
|
{projects.map((p) => (
|
|
<DropdownMenuItem key={p.id} onClick={() => onUpdate({ project_id: p.id })}>
|
|
<ProjectIcon project={p} size="md" className="mr-1" />
|
|
<span className="truncate">{p.title}</span>
|
|
{p.id === projectId && <Check className="ml-auto h-3.5 w-3.5 shrink-0" />}
|
|
</DropdownMenuItem>
|
|
))}
|
|
{projects.length > 0 && projectId && <DropdownMenuSeparator />}
|
|
{projectId && (
|
|
<DropdownMenuItem onClick={() => onUpdate({ project_id: null })}>
|
|
<X className="h-3.5 w-3.5 text-muted-foreground" />
|
|
{t(($) => $.picker.remove)}
|
|
</DropdownMenuItem>
|
|
)}
|
|
{projects.length === 0 && (
|
|
<div className="px-2 py-1.5 text-xs text-muted-foreground">{t(($) => $.picker.empty)}</div>
|
|
)}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
);
|
|
}
|