Compare commits

...

2 Commits

Author SHA1 Message Date
Jiang Bohan
b59ff18e93 refactor(issues): hide metadata behind a JSON dialog (MUL-2503)
Metadata is an agent-facing free-form KV bag — the values almost never
mean anything to a human reader, and every property humans actually care
about already has a dedicated sidebar field (status, priority, assignee,
etc.). Rendering the first four keys inline still pushed real signal
down and added visual noise for no benefit, so drop the inline strip
entirely.

Replace the section with a small `{ }` Metadata button at the bottom of
the sidebar that opens a Dialog showing the formatted JSON. The button
hides itself when the bag is empty, so the common case stays completely
quiet. Removes the prior collapse threshold (and its `Show N more` /
`Show less` strings) since there is nothing to collapse anymore.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-21 17:17:45 +08:00
Jiang Bohan
5348ea4c71 feat(issues): collapse long metadata bags in sidebar (MUL-2503)
The metadata KV strip rendered every key inline, so issues with many
pinned keys pushed the rest of the sidebar far down. Keep the first
four rows visible and tuck the remainder behind a Show N more / Show
less toggle once the bag reaches five keys, mirroring the PR list
collapse rule.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-21 17:03:29 +08:00
4 changed files with 92 additions and 28 deletions

View File

@@ -667,6 +667,67 @@ describe("IssueDetail (shared)", () => {
expect(screen.queryByText("Properties")).not.toBeInTheDocument();
});
it("hides metadata content from the sidebar and shows a button when the bag has keys", async () => {
// Metadata is agent-facing; the sidebar only exposes a button that opens
// the raw JSON on demand. Keys are NOT rendered inline anywhere.
mockApiObj.getIssue.mockResolvedValue({
...mockIssue,
metadata: {
pr_url: "https://example.com/pr/1",
pipeline_status: "running",
},
});
renderIssueDetail();
await waitFor(() => {
expect(screen.getByRole("button", { name: "Metadata" })).toBeInTheDocument();
});
// Key names are not rendered in the sidebar prior to opening the dialog.
expect(screen.queryByText("pr_url")).not.toBeInTheDocument();
expect(screen.queryByText("pipeline_status")).not.toBeInTheDocument();
});
it("opens a dialog with formatted JSON when the Metadata button is clicked", async () => {
mockApiObj.getIssue.mockResolvedValue({
...mockIssue,
metadata: {
pr_url: "https://example.com/pr/1",
pipeline_status: "running",
},
});
renderIssueDetail();
const button = await screen.findByRole("button", { name: "Metadata" });
fireEvent.click(button);
// The dialog renders a <pre> containing the formatted JSON; checking the
// exact serialized payload also verifies the indent / structure.
const expected = JSON.stringify(
{ pr_url: "https://example.com/pr/1", pipeline_status: "running" },
null,
2,
);
await waitFor(() => {
const pre = document.querySelector("pre");
expect(pre).not.toBeNull();
expect(pre!.textContent).toBe(expected);
});
});
it("hides the Metadata button entirely when the bag is empty", async () => {
// Default fixture already has metadata: {}, asserted explicitly here.
renderIssueDetail();
await waitFor(() => {
expect(screen.getByText("Details")).toBeInTheDocument();
});
expect(screen.queryByRole("button", { name: "Metadata" })).not.toBeInTheDocument();
});
it("renders Details section with Created by and dates", async () => {
renderIssueDetail();

View File

@@ -7,6 +7,7 @@ import { AppLink } from "../../navigation";
import { useNavigation } from "../../navigation";
import {
Archive,
Braces,
Calendar,
CalendarClock,
CalendarDays,
@@ -35,6 +36,7 @@ import {
TooltipTrigger,
TooltipContent,
} from "@multica/ui/components/ui/tooltip";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@multica/ui/components/ui/dialog";
import { Popover, PopoverTrigger, PopoverContent } from "@multica/ui/components/ui/popover";
import { Checkbox } from "@multica/ui/components/ui/checkbox";
import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "@multica/ui/components/ui/command";
@@ -651,7 +653,7 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
const [detailsOpen, setDetailsOpen] = useState(true);
const [parentIssueOpen, setParentIssueOpen] = useState(true);
const [pullRequestsOpen, setPullRequestsOpen] = useState(true);
const [metadataOpen, setMetadataOpen] = useState(true);
const [metadataDialogOpen, setMetadataDialogOpen] = useState(false);
const [tokenUsageOpen, setTokenUsageOpen] = useState(true);
const githubSettings = useGitHubSettings();
@@ -1386,33 +1388,6 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
</div>
)}
{/* Metadata — read-only KV strip. Agents write via the CLI / metadata
API; UI editing is intentionally not in V1. Section hides itself
when the issue has no keys to keep the sidebar quiet for the
common case where metadata is never set. */}
{Object.keys(issue.metadata ?? {}).length > 0 && (
<div>
<button
className={`flex w-full items-center gap-1 rounded-md px-2 py-1 text-xs font-medium transition-colors mb-2 hover:bg-accent/70 ${metadataOpen ? "" : "text-muted-foreground hover:text-foreground"}`}
onClick={() => setMetadataOpen(!metadataOpen)}
>
{t(($) => $.detail.section_metadata)}
<ChevronRight className={`!size-3 shrink-0 stroke-[2.5] text-muted-foreground transition-transform ${metadataOpen ? "rotate-90" : ""}`} />
</button>
{metadataOpen && (
<div className="grid grid-cols-[auto_1fr] gap-x-2 gap-y-0.5 pl-2">
{Object.entries(issue.metadata).map(([k, v]) => (
<PropRow key={k} label={k} interactive={false}>
<span className="truncate text-muted-foreground">
{typeof v === "boolean" ? (v ? "true" : "false") : String(v)}
</span>
</PropRow>
))}
</div>
)}
</div>
)}
{/* Details */}
<div>
<button
@@ -1474,6 +1449,32 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
</div>}
</div>
)}
{/* Metadata — agent-facing free-form KV bag. The values almost never
mean anything to humans, so we don't render them in the sidebar;
instead a small button reveals the raw JSON on demand. Button
hides itself when the bag is empty to keep the sidebar quiet. */}
{Object.keys(issue.metadata ?? {}).length > 0 && (
<button
type="button"
onClick={() => setMetadataDialogOpen(true)}
className="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-accent/70 hover:text-foreground"
>
<Braces className="!size-3 shrink-0 stroke-[2.5]" />
{t(($) => $.detail.section_metadata)}
</button>
)}
<Dialog open={metadataDialogOpen} onOpenChange={setMetadataDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t(($) => $.detail.metadata_dialog_title)}</DialogTitle>
</DialogHeader>
<pre className="max-h-[60vh] overflow-auto rounded-md bg-muted p-3 font-mono text-xs">
{JSON.stringify(issue.metadata ?? {}, null, 2)}
</pre>
</DialogContent>
</Dialog>
</div>
);

View File

@@ -143,6 +143,7 @@
"section_parent_issue": "Parent issue",
"section_pull_requests": "Pull requests",
"section_metadata": "Metadata",
"metadata_dialog_title": "Metadata",
"section_details": "Details",
"section_token_usage": "Token usage",
"pull_requests_loading": "Loading…",

View File

@@ -142,6 +142,7 @@
"section_parent_issue": "父 issue",
"section_pull_requests": "Pull Request",
"section_metadata": "元数据",
"metadata_dialog_title": "元数据",
"section_details": "详情",
"section_token_usage": "Token 用量",
"pull_requests_loading": "加载中…",