From 57be69517f9711ef046e753d2ca85b0bcc9259cc Mon Sep 17 00:00:00 2001 From: Bohan Jiang <52446949+Bohan-J@users.noreply.github.com> Date: Fri, 15 May 2026 18:04:33 +0800 Subject: [PATCH] feat(views): progressive disclosure for issue sidebar properties (MUL-2275) (#2675) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(views): progressive disclosure for issue sidebar properties (MUL-2275) Split sidebar Properties into a core group that always renders (status / priority / assignee / labels) and an optional group (due_date / project / parent) that only appears when the issue has the value set or the user explicitly added it via a new "+ Add property" picker. A field cleared in-session stays visible to avoid row flicker; navigating to a different issue reseeds visibility from that issue's set fields. The standalone "Parent issue" card is folded into Properties as one of those optional rows. Adds `defaultOpen` to DueDatePicker / ProjectPicker so a newly-added row drops the user straight into edit state. Co-authored-by: multica-agent * refactor(views): swap sidebar optional set to due_date + labels Per design feedback: status / priority / assignee / project / parent are all required and should always render in the sidebar; only due_date and labels are progressive-disclosure optionals. Move project and parent rows out of the optional block (drop their +Add property menu entries and the parent special-case in addOptionalProp). Move labels into the optional block, gated on the issue's actual attached- label count (queried via issueLabelsOptions), with defaultOpen wired through LabelPicker so picking "Labels" from +Add property drops the user straight into the picker. Tests updated for the new split. Co-authored-by: multica-agent * refactor(views): restore standalone parent card, move priority to optional Parent goes back to its own collapsible section, rendered only when the issue actually has a parent — matching the pre-MUL-2275 behavior. It is no longer interleaved with Properties rows. Priority joins the progressive-disclosure set (priority / due_date / labels). New issues default to priority "none", so the row is hidden until set or added via "+ Add property", and PriorityPicker gains defaultOpen so the field drops straight into edit state when chosen from the add-property menu. Co-authored-by: multica-agent * refactor(issue-detail): tighten Add-property popover visual rhythm Picked up a small visual inconsistency while reviewing the PR's UI: the "Add property" dropdown floated above the inspector at a noticeably larger type scale than the property rows, and each item was bare text while the rows it sat above all rendered with an icon + value pair. Tweaks: - Items: `text-sm py-1.5` → `text-xs py-1`, matching the inspector row typography and trimming row-to-row gap from 12px to 8px. - Each option leads with the icon the resulting picker uses (`PriorityIcon` bars / `CalendarDays` / `Tag`) so the dropdown reads as a preview of what will appear in the new PropRow. - Focus indicator: replace the default thick focus ring with `focus-visible:bg-accent + outline-none`, matching the hover state language — keyboard focus and mouse hover now look the same. - Popover width: `w-48` → `w-44` since the labels are short and the visual is now denser; still leaves room for translated strings. * fix(issue-detail): dismiss Add-property popover when an option is picked Base UI's `Popover` doesn't auto-dismiss when a child is clicked (it's not a Menu primitive), so picking an option left the "+ Add property" popover sitting behind the picker that auto-opens for the newly added row — two popovers visibly stacked. Make the Popover controlled with a local `addPropPopoverOpen` state and close it inside `addOptionalProp` right after enqueuing the row's auto-open. The picker still pops on mount via `defaultOpen={autoOpenProp === key}`, so the user flow is unchanged from their perspective: Click "+ Add property" → menu opens Click an option → menu closes AND target picker opens (Was the same flow on paper before; just had the orphan popover behind the picker.) --------- Co-authored-by: multica-agent --- .../issues/components/issue-detail.test.tsx | 41 +++- .../views/issues/components/issue-detail.tsx | 214 +++++++++++++++++- .../components/pickers/due-date-picker.tsx | 6 +- .../components/pickers/label-picker.tsx | 6 +- .../components/pickers/priority-picker.tsx | 6 +- packages/views/locales/en/issues.json | 1 + packages/views/locales/zh-Hans/issues.json | 1 + .../projects/components/project-picker.tsx | 6 +- 8 files changed, 264 insertions(+), 17 deletions(-) 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 && (