diff --git a/packages/views/issues/components/issue-detail.test.tsx b/packages/views/issues/components/issue-detail.test.tsx index e044a5282..88b830a75 100644 --- a/packages/views/issues/components/issue-detail.test.tsx +++ b/packages/views/issues/components/issue-detail.test.tsx @@ -542,17 +542,54 @@ describe("IssueDetail (shared)", () => { expect(wsLink.closest("a")).toHaveAttribute("href", "/test/issues"); }); - it("renders properties sidebar with status, priority, assignee, due date", async () => { + it("renders properties sidebar with all core rows plus set optional rows", async () => { renderIssueDetail(); await waitFor(() => { expect(screen.getByText("Properties")).toBeInTheDocument(); }); + // Core rows — always rendered regardless of whether the issue has a value. expect(screen.getByText("Status")).toBeInTheDocument(); - expect(screen.getByText("Priority")).toBeInTheDocument(); expect(screen.getByText("Assignee")).toBeInTheDocument(); + // "Project" appears twice (row label + picker stub), so disambiguate by id. + expect(screen.getByTestId("project-picker")).toBeInTheDocument(); + // priority="high" + due_date are set in the fixture, so both optional rows show. + expect(screen.getByText("Priority")).toBeInTheDocument(); expect(screen.getByText("Due date")).toBeInTheDocument(); + // No labels are attached in the fixture — the Labels optional row + // must stay hidden by default. + expect(screen.queryByText("Labels")).not.toBeInTheDocument(); + // Parent issue lives in its own section and only renders when the + // issue actually has a parent — the fixture has none. + expect(screen.queryByText("Parent issue")).not.toBeInTheDocument(); + // The "+ Add property" affordance is always offered while any + // optional field is still hidden. + expect(screen.getByText("Add property")).toBeInTheDocument(); + }); + + it("hides every optional property row when none are set", async () => { + // Override the default fixture: nothing optional set. + mockApiObj.getIssue.mockResolvedValue({ + ...mockIssue, + priority: "none", + due_date: null, + }); + + renderIssueDetail(); + + await waitFor(() => { + expect(screen.getByText("Properties")).toBeInTheDocument(); + }); + + expect(screen.queryByText("Priority")).not.toBeInTheDocument(); + expect(screen.queryByText("Due date")).not.toBeInTheDocument(); + expect(screen.queryByText("Labels")).not.toBeInTheDocument(); + // Project stays as a core row regardless of value. + expect(screen.getByTestId("project-picker")).toBeInTheDocument(); + // No parent → no standalone Parent issue section either. + expect(screen.queryByText("Parent issue")).not.toBeInTheDocument(); + expect(screen.getByText("Add property")).toBeInTheDocument(); }); it("uses a non-resizable layout with the sidebar sheet closed by default on mobile", async () => { diff --git a/packages/views/issues/components/issue-detail.tsx b/packages/views/issues/components/issue-detail.tsx index 51ed6a799..66f076c49 100644 --- a/packages/views/issues/components/issue-detail.tsx +++ b/packages/views/issues/components/issue-detail.tsx @@ -8,6 +8,7 @@ import { useNavigation } from "../../navigation"; import { Archive, Calendar, + CalendarDays, ChevronDown, ChevronLeft, ChevronRight, @@ -17,6 +18,7 @@ import { Pin, PinOff, Plus, + Tag, Users, } from "lucide-react"; import { PageHeader } from "../../layout/page-header"; @@ -58,6 +60,7 @@ import { useCurrentWorkspace, useWorkspacePaths } from "@multica/core/paths"; import { useActorName } from "@multica/core/workspace/hooks"; import { useWorkspaceId } from "@multica/core/hooks"; import { issueListOptions, issueDetailOptions, childIssuesOptions, issueUsageOptions, issueAttachmentsOptions } from "@multica/core/issues/queries"; +import { issueLabelsOptions } from "@multica/core/labels"; import { memberListOptions, agentListOptions } from "@multica/core/workspace/queries"; import { useRecentIssuesStore } from "@multica/core/issues/stores"; import { useIssueSelectionStore } from "@multica/core/issues/stores/selection-store"; @@ -265,6 +268,41 @@ function formatTokenCount(n: number): string { // new array on every render and bust React.memo on CommentCard / ResolvedThreadBar. const EMPTY_REPLIES: TimelineEntry[] = []; +// --------------------------------------------------------------------------- +// Sidebar progressive disclosure +// --------------------------------------------------------------------------- +// +// Properties shown in the sidebar split into two groups: +// - core: always rendered (status / assignee / project) +// - optional: rendered only when the issue has a value for that field OR +// the user explicitly added it via "+ Add property" in this session +// (priority / due_date / labels) +// +// Parent is not in either group — it has its own standalone section below +// the Properties block, rendered only when the issue actually has a parent. +// +// `OPTIONAL_PROP_KEYS` is the open set — adding a new optional field +// (e.g. `start_date`) means appending here, wiring its row in the JSX +// switch below, and adding a locale key. The picker, visibility rules, +// and add-property menu all flow from this one list. +const OPTIONAL_PROP_KEYS = ["priority", "due_date", "labels"] as const; +type OptionalPropKey = (typeof OPTIONAL_PROP_KEYS)[number]; + +function isOptionalPropSet( + issue: Issue, + key: OptionalPropKey, + attachedLabelsCount: number, +): boolean { + switch (key) { + case "priority": + return issue.priority !== "none"; + case "due_date": + return !!issue.due_date; + case "labels": + return attachedLabelsCount > 0; + } +} + // Shallow array equality by element identity. Used to reuse the previous // render's per-thread reply slice when nothing in *this* thread changed, // even if the surrounding `timeline` array was rebuilt by a WS event in @@ -593,6 +631,24 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr const [parentIssueOpen, setParentIssueOpen] = useState(true); const [pullRequestsOpen, setPullRequestsOpen] = useState(true); const [tokenUsageOpen, setTokenUsageOpen] = useState(true); + + // Per-issue, per-session set of optional properties currently visible in + // the sidebar Properties section. Seeded on issue switch with whichever + // fields are already set; "+ Add property" adds an entry, clearing a + // value does *not* remove one (avoids row-flicker on edit → clear). + // Resets when the user navigates to a different issue. + const [visibleOptionalProps, setVisibleOptionalProps] = useState>( + () => new Set(), + ); + // Optional property to auto-open as soon as it's mounted (the user just + // picked it from "+ Add property" and we want them dropped straight into + // edit state). Consumed by the row that matches this key, cleared after. + const [autoOpenProp, setAutoOpenProp] = useState(null); + // Controlled state for the "+ Add property" popover. Base UI's Popover + // doesn't auto-dismiss on item click (it's not a Menu primitive), so the + // popover would stay open behind the newly auto-opened picker — two + // popovers stacked. We close it explicitly in `addOptionalProp`. + const [addPropPopoverOpen, setAddPropPopoverOpen] = useState(false); // Virtuoso's `customScrollParent` wants the HTMLElement, not a ref. A plain // `useRef.current` does not trigger a re-render when it populates, so the // Virtuoso prop would never receive the element. Callback ref + state fixes @@ -1002,6 +1058,66 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr const actions = useIssueActions(issue); const handleUpdateField = actions.updateField; + // Labels live in their own query (not on the issue body) — fetch the count + // here so seeding can decide whether the "Labels" optional row should be + // shown for an issue that already has labels attached. + const { data: attachedLabels = [] } = useQuery(issueLabelsOptions(wsId, id)); + const attachedLabelsCount = attachedLabels.length; + + // Seed the visible-optional-props set: + // - on issue switch, reset to whichever fields are currently set + // - on the SAME issue, additively pick up fields the user just set + // (so the row stays visible after they edit + clear in one session) + // Removal happens only on issue switch — never on clear. + const seededIssueIdRef = useRef(null); + useEffect(() => { + if (!issue) return; + if (seededIssueIdRef.current !== issue.id) { + seededIssueIdRef.current = issue.id; + setAutoOpenProp(null); + const seed = new Set(); + for (const k of OPTIONAL_PROP_KEYS) { + if (isOptionalPropSet(issue, k, attachedLabelsCount)) seed.add(k); + } + setVisibleOptionalProps(seed); + return; + } + setVisibleOptionalProps((prev) => { + let next = prev; + for (const k of OPTIONAL_PROP_KEYS) { + if (isOptionalPropSet(issue, k, attachedLabelsCount) && !next.has(k)) { + if (next === prev) next = new Set(prev); + next.add(k); + } + } + return next; + }); + }, [issue, attachedLabelsCount]); + + const addOptionalProp = useCallback( + (key: OptionalPropKey) => { + setVisibleOptionalProps((prev) => { + if (prev.has(key)) return prev; + const next = new Set(prev); + next.add(key); + return next; + }); + setAutoOpenProp(key); + // Dismiss the "+ Add property" popover so it doesn't sit stacked + // behind the picker we're about to auto-open. + setAddPropPopoverOpen(false); + }, + [], + ); + + // Clear the auto-open flag after the next render so pickers (which read + // `defaultOpen` once via a useState initializer) keep the open state they + // captured on mount, but later interactions don't re-trigger it. + useEffect(() => { + if (autoOpenProp === null) return; + setAutoOpenProp(null); + }, [autoOpenProp]); + const handleToggleSidebar = useCallback(() => { if (isMobile) { setMobileSidebarOpen((open) => !open); @@ -1090,28 +1206,104 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr {propertiesOpen &&
+ {/* Core props — always rendered. */} $.detail.prop_status)}> - $.detail.prop_priority)}> - - $.detail.prop_assignee)}> - $.detail.prop_due_date)}> - - $.detail.prop_project)}> - - - $.detail.prop_labels)}> - + + + {/* Optional props — rendered only when set on the issue OR added + via "+ Add property" in this session. Row order follows the + order of `OPTIONAL_PROP_KEYS`. */} + {visibleOptionalProps.has("priority") && ( + $.detail.prop_priority)}> + + + )} + {visibleOptionalProps.has("due_date") && ( + $.detail.prop_due_date)}> + + + )} + {visibleOptionalProps.has("labels") && ( + $.detail.prop_labels)}> + + + )} + + {/* "+ Add property" — opens a Popover listing optional fields + not yet displayed. Hidden once every optional field is on + screen. Sits inside the same grid as a full-row, with its + own padding so the visual rhythm follows the rows above. */} + {OPTIONAL_PROP_KEYS.some((k) => !visibleOptionalProps.has(k)) && ( +
+ + + + {t(($) => $.detail.add_property_action)} + + {/* Item visuals mirror the inspector rows' typography + (text-xs, muted icons) and each option leads with the + icon the resulting picker uses, so the dropdown reads + as a preview of what will show up below. */} + + {OPTIONAL_PROP_KEYS.filter((k) => !visibleOptionalProps.has(k)).map((k) => ( + + ))} + + +
+ )}
} - {/* Parent issue */} + {/* Parent issue — standalone section, only when the issue has a + parent. Setting a parent is reachable via the issue actions menu; + this card surfaces an existing parent without occupying sidebar + space for issues that don't have one. */} {parentIssue && (