feat(views): progressive disclosure for issue sidebar properties (MUL-2275) (#2675)

* 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 <github@multica.ai>

* 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 <github@multica.ai>

* 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 <github@multica.ai>

* 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 <github@multica.ai>
This commit is contained in:
Bohan Jiang
2026-05-15 18:04:33 +08:00
committed by GitHub
parent f64d182fd1
commit 57be69517f
8 changed files with 264 additions and 17 deletions

View File

@@ -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 () => {

View File

@@ -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<Set<OptionalPropKey>>(
() => 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<OptionalPropKey | null>(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<string | null>(null);
useEffect(() => {
if (!issue) return;
if (seededIssueIdRef.current !== issue.id) {
seededIssueIdRef.current = issue.id;
setAutoOpenProp(null);
const seed = new Set<OptionalPropKey>();
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
<ChevronRight className={`!size-3 shrink-0 stroke-[2.5] text-muted-foreground transition-transform ${propertiesOpen ? "rotate-90" : ""}`} />
</button>
{propertiesOpen && <div className="grid grid-cols-[auto_1fr] gap-x-2 gap-y-0.5 pl-2">
{/* Core props — always rendered. */}
<PropRow label={t(($) => $.detail.prop_status)}>
<StatusPicker status={issue.status} onUpdate={handleUpdateField} align="start" />
</PropRow>
<PropRow label={t(($) => $.detail.prop_priority)}>
<PriorityPicker priority={issue.priority} onUpdate={handleUpdateField} align="start" />
</PropRow>
<PropRow label={t(($) => $.detail.prop_assignee)}>
<AssigneePicker assigneeType={issue.assignee_type} assigneeId={issue.assignee_id} onUpdate={handleUpdateField} align="start" />
</PropRow>
<PropRow label={t(($) => $.detail.prop_due_date)}>
<DueDatePicker dueDate={issue.due_date} onUpdate={handleUpdateField} />
</PropRow>
<PropRow label={t(($) => $.detail.prop_project)}>
<ProjectPicker projectId={issue.project_id} onUpdate={handleUpdateField} />
</PropRow>
<PropRow label={t(($) => $.detail.prop_labels)}>
<LabelPicker issueId={issue.id} align="start" />
<ProjectPicker
projectId={issue.project_id}
onUpdate={handleUpdateField}
/>
</PropRow>
{/* 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") && (
<PropRow label={t(($) => $.detail.prop_priority)}>
<PriorityPicker
priority={issue.priority}
onUpdate={handleUpdateField}
align="start"
defaultOpen={autoOpenProp === "priority"}
/>
</PropRow>
)}
{visibleOptionalProps.has("due_date") && (
<PropRow label={t(($) => $.detail.prop_due_date)}>
<DueDatePicker
dueDate={issue.due_date}
onUpdate={handleUpdateField}
defaultOpen={autoOpenProp === "due_date"}
/>
</PropRow>
)}
{visibleOptionalProps.has("labels") && (
<PropRow label={t(($) => $.detail.prop_labels)}>
<LabelPicker
issueId={issue.id}
align="start"
defaultOpen={autoOpenProp === "labels"}
/>
</PropRow>
)}
{/* "+ 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)) && (
<div className="col-span-2 mt-1">
<Popover open={addPropPopoverOpen} onOpenChange={setAddPropPopoverOpen}>
<PopoverTrigger
className="flex items-center gap-1.5 rounded-md px-2 py-1 -mx-2 text-xs text-muted-foreground hover:bg-accent/50 hover:text-foreground transition-colors"
>
<Plus className="h-3 w-3 shrink-0" />
<span>{t(($) => $.detail.add_property_action)}</span>
</PopoverTrigger>
{/* 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. */}
<PopoverContent align="start" className="w-44 p-1">
{OPTIONAL_PROP_KEYS.filter((k) => !visibleOptionalProps.has(k)).map((k) => (
<button
key={k}
type="button"
onClick={() => addOptionalProp(k)}
className="flex w-full items-center gap-2 rounded-md px-2 py-1 text-xs text-foreground/90 transition-colors hover:bg-accent focus-visible:bg-accent focus-visible:outline-none"
>
{k === "priority" && (
<PriorityIcon priority="medium" inheritColor className="text-muted-foreground" />
)}
{k === "due_date" && (
<CalendarDays className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
)}
{k === "labels" && (
<Tag className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
)}
<span className="truncate">
{k === "priority" && t(($) => $.detail.prop_priority)}
{k === "due_date" && t(($) => $.detail.prop_due_date)}
{k === "labels" && t(($) => $.detail.prop_labels)}
</span>
</button>
))}
</PopoverContent>
</Popover>
</div>
)}
</div>}
</div>
{/* 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 && (
<div>
<button

View File

@@ -18,15 +18,19 @@ export function DueDatePicker({
trigger: customTrigger,
triggerRender,
align = "start",
defaultOpen = false,
}: {
dueDate: string | null;
onUpdate: (updates: Partial<UpdateIssueRequest>) => void;
trigger?: React.ReactNode;
triggerRender?: React.ReactElement;
align?: "start" | "center" | "end";
/** Open the popover on first mount. Used by progressive-disclosure
* sidebars so a newly-added field immediately enters edit state. */
defaultOpen?: boolean;
}) {
const { t } = useT("issues");
const [open, setOpen] = useState(false);
const [open, setOpen] = useState(defaultOpen);
const date = dueDate ? new Date(dueDate) : undefined;
const isOverdue = date ? date < new Date() : false;

View File

@@ -28,6 +28,9 @@ interface LabelPickerProps {
open?: boolean;
onOpenChange?: (open: boolean) => void;
align?: "start" | "center" | "end";
/** Open the picker on first mount. Used by progressive-disclosure
* sidebars so a newly-added field immediately enters edit state. */
defaultOpen?: boolean;
}
/**
@@ -67,9 +70,10 @@ export function LabelPicker({
open: controlledOpen,
onOpenChange,
align = "start",
defaultOpen = false,
}: LabelPickerProps) {
const { t } = useT("issues");
const [internalOpen, setInternalOpen] = useState(false);
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const open = controlledOpen ?? internalOpen;
const setOpen = onOpenChange ?? setInternalOpen;
const [filter, setFilter] = useState("");

View File

@@ -15,6 +15,7 @@ export function PriorityPicker({
open: controlledOpen,
onOpenChange: controlledOnOpenChange,
align,
defaultOpen = false,
}: {
priority: IssuePriority;
onUpdate: (updates: Partial<UpdateIssueRequest>) => void;
@@ -23,8 +24,11 @@ export function PriorityPicker({
open?: boolean;
onOpenChange?: (v: boolean) => void;
align?: "start" | "center" | "end";
/** Open the picker on first mount. Used by progressive-disclosure
* sidebars so a newly-added field immediately enters edit state. */
defaultOpen?: boolean;
}) {
const [internalOpen, setInternalOpen] = useState(false);
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const open = controlledOpen ?? internalOpen;
const setOpen = controlledOnOpenChange ?? setInternalOpen;
const { t } = useT("issues");

View File

@@ -136,6 +136,7 @@
"prop_due_date": "Due date",
"prop_project": "Project",
"prop_labels": "Labels",
"add_property_action": "Add property",
"prop_created_by": "Created by",
"prop_created": "Created",
"prop_updated": "Updated",

View File

@@ -135,6 +135,7 @@
"prop_due_date": "截止日期",
"prop_project": "项目",
"prop_labels": "标签",
"add_property_action": "添加字段",
"prop_created_by": "创建者",
"prop_created": "创建时间",
"prop_updated": "更新时间",

View File

@@ -20,11 +20,15 @@ export function ProjectPicker({
onUpdate,
triggerRender,
align = "start",
defaultOpen = 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;
}) {
const { t } = useT("projects");
const wsId = useWorkspaceId();
@@ -32,7 +36,7 @@ export function ProjectPicker({
const current = projects.find((p) => p.id === projectId);
return (
<DropdownMenu>
<DropdownMenu defaultOpen={defaultOpen}>
<DropdownMenuTrigger
className={triggerRender ? undefined : "flex items-center gap-1.5 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors overflow-hidden"}
render={triggerRender}