Compare commits

...

4 Commits

Author SHA1 Message Date
Naiyuan Qing
6be967e0ac fix(list): improve drag-and-drop for empty status groups
Empty groups had a ~48px drop target with no visual feedback, making
drags feel rigid compared to the board view. Three fixes:

- Add makeListCollision that restricts closestCenter fallback to group
  containers only, preventing oscillation between adjacent vertical groups
- Expand header ring highlight to cover expanded empty groups and
  non-manual sort mode (was collapsed-only)
- Replace the empty-state <p> with a min-h-20 drop zone that shows
  ring + background on hover

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 17:31:35 +08:00
Naiyuan Qing
f2ac9d7715 fix(header): restore swimlane option in view toggle dropdown
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 17:02:57 +08:00
Naiyuan Qing
881f81fc42 fix(my-issues): move MyIssuesHeader inside ViewStoreProvider
IssueDisplayControls calls useViewStore which requires the provider context.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 16:55:19 +08:00
Naiyuan Qing
a2dd8c2bd0 feat(list): drag-and-drop reordering + shared drag utils
- List view drag-and-drop: reorder within/across status groups, drop into
  collapsed groups, DraggableListRow with useSortable + checkbox protection
- Extract shared drag utilities (computePosition, findColumn, buildColumns,
  makeKanbanCollision, issueMatchesGroup, getMoveUpdates) to drag-utils.ts
- Reuse IssueDisplayControls in MyIssues header (dedup ~380 lines)
- Radio check marks for grouping/sort dropdowns
- Remove outer p-1 wrapper, unify board view to p-2

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 16:49:06 +08:00
10 changed files with 617 additions and 600 deletions

View File

@@ -206,7 +206,7 @@ export function ActorIssuesPanel({
<p className="text-sm">{t(($) => $.actor_issues.search_empty)}</p>
</div>
) : (
<div className="flex flex-1 min-h-0 flex-col">
<div className="flex flex-1 min-h-0 flex-col p-1">
<ListView
issues={issues}
visibleStatuses={visibleStatuses}

View File

@@ -7,16 +7,13 @@ import {
PointerSensor,
useSensor,
useSensors,
pointerWithin,
closestCenter,
type CollisionDetection,
type DragStartEvent,
type DragEndEvent,
type DragOverEvent,
} from "@dnd-kit/core";
import type { QueryKey } from "@tanstack/react-query";
import { arrayMove } from "@dnd-kit/sortable";
import type { Issue, IssueAssigneeGroup, IssueAssigneeType, IssueStatus, UpdateIssueRequest } from "@multica/core/types";
import type { Issue, IssueAssigneeGroup, IssueStatus } from "@multica/core/types";
import { useLoadMoreByAssigneeGroup, useLoadMoreByStatus } from "@multica/core/issues/mutations";
import type { AssigneeGroupedIssuesFilter, IssueSortParam, MyIssuesFilter } from "@multica/core/issues/queries";
import { useViewStore } from "@multica/core/issues/stores/view-store-context";
@@ -28,41 +25,17 @@ import { HiddenColumnsPanel, HiddenColumnRow } from "./hidden-columns-panel";
import { InfiniteScrollSentinel } from "./infinite-scroll-sentinel";
import type { ChildProgress } from "./list-row";
import { useT } from "../../i18n";
type BoardMoveUpdates = Pick<
UpdateIssueRequest,
"status" | "assignee_type" | "assignee_id" | "position"
>;
const UNASSIGNED_GROUP_ID = "assignee:unassigned";
function makeKanbanCollision(columnIds: Set<string>): CollisionDetection {
return (args) => {
const pointer = pointerWithin(args);
if (pointer.length > 0) {
const cards = pointer.filter((c) => !columnIds.has(c.id as string));
if (cards.length > 0) return cards;
return pointer;
}
return closestCenter(args);
};
}
function statusGroupId(status: IssueStatus): string {
return `status:${status}`;
}
function assigneeGroupId(
type: IssueAssigneeType | null,
id: string | null,
): string {
return type && id ? `assignee:${type}:${id}` : UNASSIGNED_GROUP_ID;
}
function getIssueGroupId(issue: Issue, grouping: IssueGrouping): string {
if (grouping === "status") return statusGroupId(issue.status);
return assigneeGroupId(issue.assignee_type, issue.assignee_id);
}
import {
type DragMoveUpdates,
makeKanbanCollision,
statusGroupId,
assigneeGroupId,
buildColumns,
computePosition,
findColumn,
issueMatchesGroup,
getMoveUpdates,
} from "../utils/drag-utils";
function isStatusGroup(
group: BoardColumnGroup,
@@ -132,65 +105,6 @@ function buildGroups(
});
}
/** Build column ID arrays from TQ issue data (server-sorted). */
function buildColumns(
issues: Issue[],
groups: BoardColumnGroup[],
grouping: IssueGrouping,
): Record<string, string[]> {
const cols: Record<string, string[]> = {};
for (const group of groups) cols[group.id] = [];
for (const issue of issues) {
const gid = getIssueGroupId(issue, grouping);
if (cols[gid]) cols[gid].push(issue.id);
}
return cols;
}
/** Compute a float position for `activeId` based on its neighbors in `ids`. */
function computePosition(ids: string[], activeId: string, issueMap: Map<string, Issue>): number {
const idx = ids.indexOf(activeId);
if (idx === -1) return 0;
const getPos = (id: string) => issueMap.get(id)?.position ?? 0;
if (ids.length === 1) return issueMap.get(activeId)?.position ?? 0;
if (idx === 0) return getPos(ids[1]!) - 1;
if (idx === ids.length - 1) return getPos(ids[idx - 1]!) + 1;
return (getPos(ids[idx - 1]!) + getPos(ids[idx + 1]!)) / 2;
}
/** Find which column contains a given ID (issue or column droppable). */
function findColumn(
columns: Record<string, string[]>,
id: string,
columnIds: Set<string>,
): string | null {
if (columnIds.has(id)) return id;
for (const [columnId, ids] of Object.entries(columns)) {
if (ids.includes(id)) return columnId;
}
return null;
}
function issueMatchesGroup(issue: Issue, group: BoardColumnGroup): boolean {
if (group.status) return issue.status === group.status;
return (
(issue.assignee_type ?? null) === (group.assigneeType ?? null) &&
(issue.assignee_id ?? null) === (group.assigneeId ?? null)
);
}
function getMoveUpdates(
group: BoardColumnGroup,
position: number,
): BoardMoveUpdates {
if (group.status) return { status: group.status, position };
return {
assignee_type: group.assigneeType ?? null,
assignee_id: group.assigneeId ?? null,
position,
};
}
const EMPTY_PROGRESS_MAP = new Map<string, ChildProgress>();
const EMPTY_IDS: string[] = [];
@@ -214,7 +128,7 @@ export function BoardView({
assigneeGroupFilter?: AssigneeGroupedIssuesFilter;
visibleStatuses: IssueStatus[];
hiddenStatuses: IssueStatus[];
onMoveIssue: (issueId: string, updates: BoardMoveUpdates, onSettled?: () => void) => void;
onMoveIssue: (issueId: string, updates: DragMoveUpdates, onSettled?: () => void) => void;
childProgressMap?: Map<string, ChildProgress>;
/** When set, per-status load-more targets the scoped cache instead of the workspace one. */
myIssuesScope?: string;
@@ -481,7 +395,7 @@ export function BoardView({
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
<div className="flex flex-1 min-h-0 gap-4 overflow-x-auto p-4">
<div className="flex flex-1 min-h-0 gap-4 overflow-x-auto p-2">
{groups.length === 0 ? (
<div className="flex min-w-full flex-1 items-center justify-center text-sm text-muted-foreground">
{t(($) => $.board.empty_grouping)}

View File

@@ -29,6 +29,8 @@ import {
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuSub,
@@ -61,6 +63,7 @@ import {
type ActorFilterValue,
} from "@multica/core/issues/stores/view-store";
import { useViewStore, useViewStoreApi } from "@multica/core/issues/stores/view-store-context";
import type { SortField, IssueGrouping, ViewMode } from "@multica/core/issues/stores/view-store";
import {
useIssuesScopeStore,
type IssuesScope,
@@ -896,14 +899,13 @@ export function IssueDisplayControls({
}
/>
<DropdownMenuContent align="start" className="w-auto">
{GROUPING_OPTIONS.map((opt) => (
<DropdownMenuItem
key={opt.value}
onClick={() => act.setGrouping(opt.value)}
>
{t(($) => $.display[GROUPING_LABEL_KEY[opt.value]])}
</DropdownMenuItem>
))}
<DropdownMenuRadioGroup value={grouping} onValueChange={(v) => act.setGrouping(v as IssueGrouping)}>
{GROUPING_OPTIONS.map((opt) => (
<DropdownMenuRadioItem key={opt.value} value={opt.value}>
{t(($) => $.display[GROUPING_LABEL_KEY[opt.value]])}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
@@ -929,14 +931,13 @@ export function IssueDisplayControls({
}
/>
<DropdownMenuContent align="start" className="w-auto">
{SORT_OPTIONS.map((opt) => (
<DropdownMenuItem
key={opt.value}
onClick={() => act.setSortBy(opt.value)}
>
{t(($) => $.display[SORT_LABEL_KEY[opt.value]])}
</DropdownMenuItem>
))}
<DropdownMenuRadioGroup value={sortBy} onValueChange={(v) => act.setSortBy(v as SortField)}>
{SORT_OPTIONS.map((opt) => (
<DropdownMenuRadioItem key={opt.value} value={opt.value}>
{t(($) => $.display[SORT_LABEL_KEY[opt.value]])}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
{sortBy !== "position" && (
@@ -994,19 +995,19 @@ export function IssueDisplayControls({
<Button variant="outline" size="sm" className="gap-1 text-muted-foreground">
{viewMode === "board" ? (
<Columns3 className="size-3.5" />
) : viewMode === "gantt" && allowGantt ? (
<ChartGantt className="size-3.5" />
) : viewMode === "swimlane" ? (
<Waves className="size-3.5" />
) : viewMode === "gantt" && allowGantt ? (
<ChartGantt className="size-3.5" />
) : (
<List className="size-3.5" />
)}
{viewMode === "board"
? t(($) => $.view.board)
: viewMode === "gantt" && allowGantt
? t(($) => $.view.gantt)
: viewMode === "swimlane"
? t(($) => $.view.swimlane)
: viewMode === "gantt" && allowGantt
? t(($) => $.view.gantt)
: t(($) => $.view.list)}
</Button>
}
@@ -1016,35 +1017,37 @@ export function IssueDisplayControls({
<TooltipContent side="bottom">
{viewMode === "board"
? t(($) => $.view.tooltip_board)
: viewMode === "gantt" && allowGantt
? t(($) => $.view.tooltip_gantt)
: viewMode === "swimlane"
? t(($) => $.view.tooltip_swimlane)
: viewMode === "gantt" && allowGantt
? t(($) => $.view.tooltip_gantt)
: t(($) => $.view.tooltip_list)}
</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" className="w-auto">
<DropdownMenuGroup>
<DropdownMenuLabel>{t(($) => $.view.section)}</DropdownMenuLabel>
<DropdownMenuItem onClick={() => act.setViewMode("board")}>
</DropdownMenuGroup>
<DropdownMenuRadioGroup value={viewMode} onValueChange={(v) => act.setViewMode(v as ViewMode)}>
<DropdownMenuRadioItem value="board">
<Columns3 />
{t(($) => $.view.board)}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => act.setViewMode("list")}>
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="list">
<List />
{t(($) => $.view.list)}
</DropdownMenuItem>
{allowGantt && (
<DropdownMenuItem onClick={() => act.setViewMode("gantt")}>
<ChartGantt />
{t(($) => $.view.gantt)}
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => act.setViewMode("swimlane")}>
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="swimlane">
<Waves />
{t(($) => $.view.swimlane)}
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuRadioItem>
{allowGantt && (
<DropdownMenuRadioItem value="gantt">
<ChartGantt />
{t(($) => $.view.gantt)}
</DropdownMenuRadioItem>
)}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)}

View File

@@ -236,7 +236,7 @@ export function IssuesPage() {
sort={sort}
/>
) : (
<ListView issues={issues} visibleStatuses={visibleStatuses} childProgressMap={childProgressMap} sort={sort} />
<ListView issues={issues} visibleStatuses={visibleStatuses} childProgressMap={childProgressMap} sort={sort} onMoveIssue={handleMoveIssue} />
)}
</div>
)}

View File

@@ -1,7 +1,10 @@
"use client";
import { memo } from "react";
import { memo, type Ref } from "react";
import { useQuery } from "@tanstack/react-query";
import { useSortable, defaultAnimateLayoutChanges } from "@dnd-kit/sortable";
import type { AnimateLayoutChanges } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { AppLink } from "../../navigation";
import type { Issue } from "@multica/core/types";
import { ActorAvatar } from "../../common/actor-avatar";
@@ -29,12 +32,22 @@ function formatDate(date: string): string {
});
}
export const ListRow = memo(function ListRow({
function ListRowContent({
issue,
childProgress,
isDragging,
containerRef,
containerStyle,
containerProps,
checkboxProps,
}: {
issue: Issue;
childProgress?: ChildProgress;
isDragging?: boolean;
containerRef?: Ref<HTMLDivElement>;
containerStyle?: React.CSSProperties;
containerProps?: Record<string, unknown>;
checkboxProps?: Pick<React.HTMLAttributes<HTMLDivElement>, "onClick" | "onMouseDown" | "onPointerDown">;
}) {
const selected = useIssueSelectionStore((s) => s.selectedIds.has(issue.id));
const toggle = useIssueSelectionStore((s) => s.toggle);
@@ -58,11 +71,17 @@ export const ListRow = memo(function ListRow({
return (
<IssueActionsContextMenu issue={issue}>
<div
ref={containerRef}
style={containerStyle}
{...containerProps}
className={`group/row flex h-9 items-center gap-2 px-4 text-sm transition-colors hover:not-data-[popup-open]:bg-accent/60 data-[popup-open]:bg-accent ${
selected ? "bg-accent/30" : ""
}`}
} ${isDragging ? "opacity-30" : ""}`}
>
<div className="relative flex shrink-0 items-center justify-center w-4 h-4">
<div
className="relative flex shrink-0 items-center justify-center w-4 h-4"
{...checkboxProps}
>
<PriorityIcon
priority={issue.priority}
className={selected ? "hidden" : "group-hover/row:hidden"}
@@ -78,7 +97,7 @@ export const ListRow = memo(function ListRow({
</div>
<AppLink
href={p.issueDetail(issue.id)}
className="flex flex-1 items-center gap-2 min-w-0"
className={`flex flex-1 items-center gap-2 min-w-0 ${isDragging ? "pointer-events-none" : ""}`}
>
<span className="w-16 shrink-0 text-xs text-muted-foreground">
{issue.identifier}
@@ -136,4 +155,65 @@ export const ListRow = memo(function ListRow({
</div>
</IssueActionsContextMenu>
);
}
export const ListRow = memo(function ListRow({
issue,
childProgress,
}: {
issue: Issue;
childProgress?: ChildProgress;
}) {
return <ListRowContent issue={issue} childProgress={childProgress} />;
});
const animateLayoutChanges: AnimateLayoutChanges = (args) => {
const { isSorting, wasDragging } = args;
if (isSorting || wasDragging) return false;
return defaultAnimateLayoutChanges(args);
};
const stopDrag = (e: React.SyntheticEvent) => {
e.stopPropagation();
};
export const DraggableListRow = memo(function DraggableListRow({
issue,
childProgress,
disableSorting,
}: {
issue: Issue;
childProgress?: ChildProgress;
disableSorting?: boolean;
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: issue.id,
data: { status: issue.status },
animateLayoutChanges,
disabled: disableSorting ? { droppable: true } : undefined,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<ListRowContent
issue={issue}
childProgress={childProgress}
isDragging={isDragging}
containerRef={setNodeRef}
containerStyle={style}
containerProps={{ ...attributes, ...listeners }}
checkboxProps={{ onClick: stopDrag, onMouseDown: stopDrag, onPointerDown: stopDrag }}
/>
);
});

View File

@@ -1,8 +1,20 @@
"use client";
import { useMemo } from "react";
import { useState, useCallback, useMemo, useEffect, useRef } from "react";
import { ChevronRight, Plus } from "lucide-react";
import { Accordion } from "@base-ui/react/accordion";
import {
DndContext,
DragOverlay,
PointerSensor,
useDroppable,
useSensor,
useSensors,
type DragStartEvent,
type DragEndEvent,
type DragOverEvent,
} from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy, arrayMove } from "@dnd-kit/sortable";
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
import { Button } from "@multica/ui/components/ui/button";
import type { Issue, IssueStatus } from "@multica/core/types";
@@ -12,11 +24,32 @@ import { useModalStore } from "@multica/core/modals";
import { useViewStore } from "@multica/core/issues/stores/view-store-context";
import { useIssueSelectionStore } from "@multica/core/issues/stores/selection-store";
import { StatusHeading } from "./status-heading";
import { ListRow, type ChildProgress } from "./list-row";
import { ListRow, DraggableListRow, type ChildProgress } from "./list-row";
import { InfiniteScrollSentinel } from "./infinite-scroll-sentinel";
import { useT } from "../../i18n";
import {
type DragMoveUpdates,
makeListCollision,
statusGroupId,
buildColumns,
computePosition,
findColumn,
issueMatchesGroup,
getMoveUpdates,
} from "../utils/drag-utils";
import type { BoardColumnGroup } from "./board-column";
const EMPTY_PROGRESS_MAP = new Map<string, ChildProgress>();
const EMPTY_IDS: string[] = [];
function buildListGroups(visibleStatuses: IssueStatus[]): BoardColumnGroup[] {
return visibleStatuses.map((status) => ({
id: statusGroupId(status),
title: status,
status,
createData: { status },
}));
}
export function ListView({
issues,
@@ -24,19 +57,18 @@ export function ListView({
childProgressMap = EMPTY_PROGRESS_MAP,
myIssuesScope,
myIssuesFilter,
sort,
projectId,
onMoveIssue,
sort,
}: {
issues: Issue[];
visibleStatuses: IssueStatus[];
childProgressMap?: Map<string, ChildProgress>;
/** When set, per-status load-more targets the scoped cache instead of the workspace one. */
myIssuesScope?: string;
myIssuesFilter?: MyIssuesFilter;
/** Must match the sort the page queried with — embedded in the cache key. */
sort?: IssueSortParam;
/** When set, the per-section "+" pre-fills the project on the create form. */
projectId?: string;
onMoveIssue?: (issueId: string, updates: DragMoveUpdates, onSettled?: () => void) => void;
sort?: IssueSortParam;
}) {
const listCollapsedStatuses = useViewStore(
(s) => s.listCollapsedStatuses
@@ -44,15 +76,13 @@ export function ListView({
const toggleListCollapsed = useViewStore(
(s) => s.toggleListCollapsed
);
const sortBy = useViewStore((s) => s.sortBy);
const { t } = useT("issues");
const issuesByStatus = useMemo(() => {
const map = new Map<IssueStatus, Issue[]>();
for (const status of visibleStatuses) {
const filtered = issues.filter((i) => i.status === status);
map.set(status, filtered);
}
return map;
}, [issues, visibleStatuses]);
const sortFieldKey = sortBy === "created_at" ? "created" : sortBy;
const sortLabel = sortBy !== "position"
? t(($) => $.board.ordered_by, { field: t(($) => $.display[`sort_${sortFieldKey}` as keyof typeof $.display]) })
: null;
const expandedStatuses = useMemo(
() =>
@@ -66,52 +96,282 @@ export function ListView({
? { scope: myIssuesScope, filter: myIssuesFilter ?? {} }
: undefined;
return (
<div className="flex-1 min-h-0 overflow-y-auto p-2 pt-0">
<Accordion.Root
multiple
className="space-y-1"
value={expandedStatuses}
onValueChange={(value: string[]) => {
for (const status of visibleStatuses) {
const wasExpanded = expandedStatuses.includes(status);
const isExpanded = value.includes(status);
if (wasExpanded !== isExpanded) {
toggleListCollapsed(status as IssueStatus);
}
const dragEnabled = !!onMoveIssue;
const groups = useMemo(
() => buildListGroups(visibleStatuses),
[visibleStatuses],
);
const groupIds = useMemo(
() => new Set(groups.map((g) => g.id)),
[groups],
);
const groupMap = useMemo(
() => new Map(groups.map((g) => [g.id, g])),
[groups],
);
// --- Drag state ---
const [activeIssue, setActiveIssue] = useState<Issue | null>(null);
const isDraggingRef = useRef(false);
const isSettlingRef = useRef(false);
const [settleVersion, setSettleVersion] = useState(0);
const [columns, setColumns] = useState<Record<string, string[]>>(() =>
buildColumns(issues, groups, "status"),
);
const columnsRef = useRef(columns);
columnsRef.current = columns;
useEffect(() => {
if (!isDraggingRef.current && !isSettlingRef.current) {
setColumns(buildColumns(issues, groups, "status"));
}
}, [issues, groups, settleVersion]);
const recentlyMovedRef = useRef(false);
useEffect(() => {
const id = requestAnimationFrame(() => {
recentlyMovedRef.current = false;
});
return () => cancelAnimationFrame(id);
}, [columns]);
const issueMap = useMemo(() => {
const map = new Map<string, Issue>();
for (const issue of issues) map.set(issue.id, issue);
return map;
}, [issues]);
const issueMapRef = useRef(issueMap);
if (!isDraggingRef.current && !isSettlingRef.current) {
issueMapRef.current = issueMap;
}
const collisionDetection = useMemo(
() => makeListCollision(groupIds),
[groupIds],
);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 5 },
})
);
const handleDragStart = useCallback(
(event: DragStartEvent) => {
isDraggingRef.current = true;
const issue = issueMapRef.current.get(event.active.id as string) ?? null;
setActiveIssue(issue);
},
[],
);
const handleDragOver = useCallback(
(event: DragOverEvent) => {
const { active, over } = event;
if (!over || recentlyMovedRef.current) return;
const activeId = active.id as string;
const overId = over.id as string;
setColumns((prev) => {
const activeCol = findColumn(prev, activeId, groupIds);
const overCol = findColumn(prev, overId, groupIds);
if (!activeCol || !overCol || activeCol === overCol) return prev;
if (sortBy !== "position") return prev;
recentlyMovedRef.current = true;
const oldIds = prev[activeCol]!.filter((id) => id !== activeId);
const newIds = [...prev[overCol]!];
const overIndex = newIds.indexOf(overId);
const insertIndex = overIndex >= 0 ? overIndex : newIds.length;
newIds.splice(insertIndex, 0, activeId);
return { ...prev, [activeCol]: oldIds, [overCol]: newIds };
});
},
[groupIds, sortBy],
);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
isDraggingRef.current = false;
setActiveIssue(null);
const resetColumns = () =>
setColumns(buildColumns(issues, groups, "status"));
if (!over || !onMoveIssue) {
resetColumns();
return;
}
const activeId = active.id as string;
const overId = over.id as string;
const cols = columnsRef.current;
const activeCol = findColumn(cols, activeId, groupIds);
const overCol = findColumn(cols, overId, groupIds);
if (!activeCol || !overCol) {
resetColumns();
return;
}
let finalColumns = cols;
if (activeCol === overCol && sortBy === "position") {
const ids = cols[activeCol]!;
const oldIndex = ids.indexOf(activeId);
const newIndex = ids.indexOf(overId);
if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) {
const reordered = arrayMove(ids, oldIndex, newIndex);
finalColumns = { ...cols, [activeCol]: reordered };
setColumns(finalColumns);
}
}
const finalCol = sortBy === "position"
? findColumn(finalColumns, activeId, groupIds)
: overCol;
if (!finalCol) {
resetColumns();
return;
}
const finalGroup = groupMap.get(finalCol);
if (!finalGroup) {
resetColumns();
return;
}
const map = issueMapRef.current;
if (sortBy !== "position") {
const currentIssue = map.get(activeId);
if (!currentIssue || issueMatchesGroup(currentIssue, finalGroup)) {
resetColumns();
return;
}
isSettlingRef.current = true;
onMoveIssue(activeId, getMoveUpdates(finalGroup, currentIssue.position), () => {
isSettlingRef.current = false;
setSettleVersion((v) => v + 1);
});
return;
}
const finalIds = finalColumns[finalCol]!;
const newPosition = computePosition(finalIds, activeId, map);
const currentIssue = map.get(activeId);
if (
currentIssue &&
issueMatchesGroup(currentIssue, finalGroup) &&
currentIssue.position === newPosition
) {
return;
}
isSettlingRef.current = true;
onMoveIssue(activeId, getMoveUpdates(finalGroup, newPosition), () => {
isSettlingRef.current = false;
});
},
[issues, groups, onMoveIssue, groupIds, groupMap, sortBy],
);
const content = (
<Accordion.Root
multiple
className="space-y-1"
value={expandedStatuses}
onValueChange={(value: string[]) => {
if (isDraggingRef.current) return;
for (const status of visibleStatuses) {
const wasExpanded = expandedStatuses.includes(status);
const isExpanded = value.includes(status);
if (wasExpanded !== isExpanded) {
toggleListCollapsed(status as IssueStatus);
}
}}
>
{visibleStatuses.map((status) => (
}
}}
>
{visibleStatuses.map((status) => {
const isExpanded = expandedStatuses.includes(status);
return (
<StatusAccordionItem
key={status}
status={status}
issues={issuesByStatus.get(status) ?? []}
issueIds={columns[statusGroupId(status)] ?? EMPTY_IDS}
issueMap={issueMapRef.current}
childProgressMap={childProgressMap}
myIssuesOpts={myIssuesOpts}
sort={sort}
projectId={projectId}
dragEnabled={dragEnabled}
isExpanded={isExpanded}
sortLabel={sortLabel}
sort={sort}
/>
))}
</Accordion.Root>
</div>
);
})}
</Accordion.Root>
);
if (!dragEnabled) {
return (
<div className="flex-1 min-h-0 overflow-y-auto p-2 pt-0">
{content}
</div>
);
}
return (
<DndContext
sensors={sensors}
collisionDetection={collisionDetection}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
<div className="flex-1 min-h-0 overflow-y-auto p-2 pt-0">
{content}
</div>
<DragOverlay dropAnimation={null}>
{activeIssue ? (
<div className="max-w-2xl rotate-1 cursor-grabbing opacity-90 shadow-lg shadow-black/10 rounded-md border border-border bg-card px-4 py-2">
<span className="text-xs text-muted-foreground mr-2">{activeIssue.identifier}</span>
<span className="text-sm">{activeIssue.title}</span>
</div>
) : null}
</DragOverlay>
</DndContext>
);
}
function StatusAccordionItem({
status,
issues,
issueIds,
issueMap,
childProgressMap,
myIssuesOpts,
sort,
projectId,
dragEnabled,
isExpanded,
sortLabel,
sort,
}: {
status: IssueStatus;
issues: Issue[];
issueIds: string[];
issueMap: Map<string, Issue>;
childProgressMap: Map<string, ChildProgress>;
myIssuesOpts?: { scope: string; filter: MyIssuesFilter };
sort?: IssueSortParam;
projectId?: string;
dragEnabled: boolean;
isExpanded: boolean;
sortLabel: string | null;
sort?: IssueSortParam;
}) {
const { t } = useT("issues");
const selectedIds = useIssueSelectionStore((s) => s.selectedIds);
@@ -123,14 +383,34 @@ function StatusAccordionItem({
sort,
);
const issueIds = issues.map((i) => i.id);
const issues = useMemo(
() => issueIds.flatMap((id) => {
const issue = issueMap.get(id);
return issue ? [issue] : [];
}),
[issueIds, issueMap],
);
const selectedCount = issueIds.filter((id) => selectedIds.has(id)).length;
const allSelected = issues.length > 0 && selectedCount === issues.length;
const someSelected = selectedCount > 0;
const { setNodeRef: setDroppableRef, isOver } = useDroppable({
id: statusGroupId(status),
disabled: !dragEnabled,
});
const disableSorting = !!sortLabel;
return (
<Accordion.Item value={status}>
<Accordion.Header className="group/header sticky top-0 z-10 flex h-10 items-center rounded-lg bg-muted transition-colors hover:bg-accent">
<Accordion.Item value={status} ref={dragEnabled ? setDroppableRef : undefined}>
<Accordion.Header
className={`group/header sticky top-0 z-10 flex h-10 items-center rounded-lg bg-muted transition-colors hover:bg-accent ${
isOver && (!isExpanded || issues.length === 0 || disableSorting)
? "ring-2 ring-brand/25 bg-accent/15"
: ""
}`}
>
<div className="pl-3 flex items-center">
<input
type="checkbox"
@@ -174,20 +454,40 @@ function StatusAccordionItem({
</Tooltip>
</div>
</Accordion.Header>
<Accordion.Panel className="pt-1">
<Accordion.Panel>
{issues.length > 0 ? (
<>
{issues.map((issue) => (
<ListRow key={issue.id} issue={issue} childProgress={childProgressMap.get(issue.id)} />
))}
{hasMore && (
<InfiniteScrollSentinel onVisible={loadMore} loading={isLoading} />
)}
</>
dragEnabled ? (
<SortableContext items={issueIds} strategy={verticalListSortingStrategy}>
{issues.map((issue) => (
<DraggableListRow
key={issue.id}
issue={issue}
childProgress={childProgressMap.get(issue.id)}
disableSorting={disableSorting}
/>
))}
{hasMore && (
<InfiniteScrollSentinel onVisible={loadMore} loading={isLoading} />
)}
</SortableContext>
) : (
<>
{issues.map((issue) => (
<ListRow key={issue.id} issue={issue} childProgress={childProgressMap.get(issue.id)} />
))}
{hasMore && (
<InfiniteScrollSentinel onVisible={loadMore} loading={isLoading} />
)}
</>
)
) : (
<p className="py-6 text-center text-xs text-muted-foreground">
{t(($) => $.list.empty_status)}
</p>
<div className={`flex min-h-20 items-center justify-center rounded-lg transition-colors ${
isOver ? "bg-accent/30 ring-2 ring-brand/25" : ""
}`}>
<p className="text-xs text-muted-foreground">
{t(($) => $.list.empty_status)}
</p>
</div>
)}
</Accordion.Panel>
</Accordion.Item>

View File

@@ -0,0 +1,117 @@
import {
pointerWithin,
closestCenter,
type CollisionDetection,
} from "@dnd-kit/core";
import type { Issue, IssueAssigneeType, IssueStatus, UpdateIssueRequest } from "@multica/core/types";
import type { IssueGrouping } from "@multica/core/issues/stores/view-store";
import type { BoardColumnGroup } from "../components/board-column";
export type DragMoveUpdates = Pick<
UpdateIssueRequest,
"status" | "assignee_type" | "assignee_id" | "position"
>;
const UNASSIGNED_GROUP_ID = "assignee:unassigned";
export function makeKanbanCollision(groupIds: Set<string>): CollisionDetection {
return (args) => {
const pointer = pointerWithin(args);
if (pointer.length > 0) {
const items = pointer.filter((c) => !groupIds.has(c.id as string));
if (items.length > 0) return items;
return pointer;
}
return closestCenter(args);
};
}
export function makeListCollision(groupIds: Set<string>): CollisionDetection {
return (args) => {
const pointer = pointerWithin(args);
if (pointer.length > 0) {
const items = pointer.filter((c) => !groupIds.has(c.id as string));
if (items.length > 0) return items;
return pointer;
}
const groupOnly = {
...args,
droppableContainers: args.droppableContainers.filter(
(c) => groupIds.has(c.id as string),
),
};
return closestCenter(groupOnly);
};
}
export function statusGroupId(status: IssueStatus): string {
return `status:${status}`;
}
export function assigneeGroupId(
type: IssueAssigneeType | null,
id: string | null,
): string {
return type && id ? `assignee:${type}:${id}` : UNASSIGNED_GROUP_ID;
}
export function getIssueGroupId(issue: Issue, grouping: IssueGrouping): string {
if (grouping === "status") return statusGroupId(issue.status);
return assigneeGroupId(issue.assignee_type, issue.assignee_id);
}
export function buildColumns(
issues: Issue[],
groups: BoardColumnGroup[],
grouping: IssueGrouping,
): Record<string, string[]> {
const cols: Record<string, string[]> = {};
for (const group of groups) cols[group.id] = [];
for (const issue of issues) {
const gid = getIssueGroupId(issue, grouping);
if (cols[gid]) cols[gid].push(issue.id);
}
return cols;
}
export function computePosition(ids: string[], activeId: string, issueMap: Map<string, Issue>): number {
const idx = ids.indexOf(activeId);
if (idx === -1) return 0;
const getPos = (id: string) => issueMap.get(id)?.position ?? 0;
if (ids.length === 1) return issueMap.get(activeId)?.position ?? 0;
if (idx === 0) return getPos(ids[1]!) - 1;
if (idx === ids.length - 1) return getPos(ids[idx - 1]!) + 1;
return (getPos(ids[idx - 1]!) + getPos(ids[idx + 1]!)) / 2;
}
export function findColumn(
columns: Record<string, string[]>,
id: string,
columnIds: Set<string>,
): string | null {
if (columnIds.has(id)) return id;
for (const [columnId, ids] of Object.entries(columns)) {
if (ids.includes(id)) return columnId;
}
return null;
}
export function issueMatchesGroup(issue: Issue, group: BoardColumnGroup): boolean {
if (group.status) return issue.status === group.status;
return (
(issue.assignee_type ?? null) === (group.assigneeType ?? null) &&
(issue.assignee_id ?? null) === (group.assigneeId ?? null)
);
}
export function getMoveUpdates(
group: BoardColumnGroup,
position: number,
): DragMoveUpdates {
if (group.status) return { status: group.status, position };
return {
assignee_type: group.assigneeType ?? null,
assignee_id: group.assigneeId ?? null,
position,
};
}

View File

@@ -2,116 +2,16 @@
import { useMemo } from "react";
import { useStore } from "zustand";
import {
ArrowDown,
ArrowUp,
Check,
ChevronDown,
CircleDot,
Columns3,
Filter,
List,
SignalHigh,
SlidersHorizontal,
Waves,
} from "lucide-react";
import { Button } from "@multica/ui/components/ui/button";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
} from "@multica/ui/components/ui/dropdown-menu";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "@multica/ui/components/ui/popover";
import { Switch } from "@multica/ui/components/ui/switch";
import {
ALL_STATUSES,
STATUS_CONFIG,
PRIORITY_ORDER,
PRIORITY_CONFIG,
} from "@multica/core/issues/config";
import { StatusIcon, PriorityIcon } from "../../issues/components";
import {
SORT_OPTIONS,
GROUPING_OPTIONS,
CARD_PROPERTY_OPTIONS,
} from "@multica/core/issues/stores/view-store";
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
import type { Issue } from "@multica/core/types";
import { myIssuesViewStore, type MyIssuesScope } from "@multica/core/issues/stores/my-issues-view-store";
import { useT } from "../../i18n";
import { WorkspaceAgentWorkingChip } from "../../issues/components/workspace-agent-working-chip";
// ---------------------------------------------------------------------------
// HoverCheck
// ---------------------------------------------------------------------------
const FILTER_ITEM_CLASS =
"group/fitem pr-1.5! [&>[data-slot=dropdown-menu-checkbox-item-indicator]]:hidden";
function HoverCheck({ checked }: { checked: boolean }) {
return (
<div
className="border-input data-[selected=true]:border-primary data-[selected=true]:bg-primary data-[selected=true]:text-primary-foreground pointer-events-none size-4 shrink-0 rounded-[4px] border transition-all select-none *:[svg]:opacity-0 data-[selected=true]:*:[svg]:opacity-100 opacity-0 group-hover/fitem:opacity-100 group-focus/fitem:opacity-100 data-[selected=true]:opacity-100"
data-selected={checked}
>
<Check className="size-3.5 text-current" />
</div>
);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function getActiveFilterCount(state: {
statusFilters: string[];
priorityFilters: string[];
}) {
let count = 0;
if (state.statusFilters.length > 0) count++;
if (state.priorityFilters.length > 0) count++;
return count;
}
function useIssueCounts(allIssues: Issue[]) {
return useMemo(() => {
const status = new Map<string, number>();
const priority = new Map<string, number>();
for (const issue of allIssues) {
status.set(issue.status, (status.get(issue.status) ?? 0) + 1);
priority.set(issue.priority, (priority.get(issue.priority) ?? 0) + 1);
}
return { status, priority };
}, [allIssues]);
}
// ---------------------------------------------------------------------------
// Scope config
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// MyIssuesHeader
// ---------------------------------------------------------------------------
import { IssueDisplayControls } from "../../issues/components/issues-header";
export function MyIssuesHeader({ allIssues }: { allIssues: Issue[] }) {
const { t } = useT("my-issues");
// Pulls the chip-wide "Viewing only working agents" label from the
// shared issues namespace so the copy stays identical with the global
// /issues page header — single source of truth for this filter cue.
const { t: tIssues } = useT("issues");
const SCOPES: { value: MyIssuesScope; label: string; description: string }[] = [
{ value: "all", label: t(($) => $.header.scope.all_label), description: t(($) => $.header.scope.all_description) },
@@ -119,41 +19,16 @@ export function MyIssuesHeader({ allIssues }: { allIssues: Issue[] }) {
{ value: "created", label: t(($) => $.header.scope.created_label), description: t(($) => $.header.scope.created_description) },
{ value: "agents", label: t(($) => $.header.scope.agents_label), description: t(($) => $.header.scope.agents_description) },
];
const viewMode = useStore(myIssuesViewStore, (s) => s.viewMode);
const statusFilters = useStore(myIssuesViewStore, (s) => s.statusFilters);
const priorityFilters = useStore(myIssuesViewStore, (s) => s.priorityFilters);
const sortBy = useStore(myIssuesViewStore, (s) => s.sortBy);
const sortDirection = useStore(myIssuesViewStore, (s) => s.sortDirection);
const grouping = useStore(myIssuesViewStore, (s) => s.grouping);
const cardProperties = useStore(myIssuesViewStore, (s) => s.cardProperties);
const scope = useStore(myIssuesViewStore, (s) => s.scope);
const agentRunningFilter = useStore(myIssuesViewStore, (s) => s.agentRunningFilter);
const act = myIssuesViewStore.getState();
// Limit the chip to issues actually visible on the My Issues page —
// without this scoping, the chip would report workspace-wide running
// agents (e.g. 3) while the my-scope list only contains one of them,
// and the post-toggle list count would never match the chip number.
const scopedIssueIds = useMemo(
() => new Set(allIssues.map((i) => i.id)),
[allIssues],
);
const counts = useIssueCounts(allIssues);
const hasActiveFilters =
getActiveFilterCount({ statusFilters, priorityFilters }) > 0;
const sortLabel =
SORT_OPTIONS.find((o) => o.value === sortBy)?.label ?? t(($) => $.header.sort_manual);
const GROUPING_LABEL_KEY: Record<typeof GROUPING_OPTIONS[number]["value"], "group_status" | "group_assignee"> = {
status: "group_status",
assignee: "group_assignee",
};
const groupingLabel = t(($) => $.header[GROUPING_LABEL_KEY[grouping]]);
return (
<div className="flex h-12 shrink-0 items-center justify-between px-4">
{/* Left: scope buttons */}
<div className="flex items-center gap-1">
{SCOPES.map((s) => (
<Tooltip key={s.value}>
@@ -178,7 +53,6 @@ export function MyIssuesHeader({ allIssues }: { allIssues: Issue[] }) {
))}
</div>
{/* Right: agent working chip + filter + display + view toggle */}
<div className="flex items-center gap-1">
{agentRunningFilter && (
<span className="mr-1 text-xs text-muted-foreground">
@@ -190,278 +64,7 @@ export function MyIssuesHeader({ allIssues }: { allIssues: Issue[] }) {
onToggle={act.toggleAgentRunningFilter}
scopedIssueIds={scopedIssueIds}
/>
{/* Filter */}
<DropdownMenu>
<Tooltip>
<DropdownMenuTrigger
render={
<TooltipTrigger
render={
<Button variant="outline" size="icon-sm" className="relative text-muted-foreground">
<Filter className="size-4" />
{hasActiveFilters && (
<span className="absolute top-0 right-0 size-1.5 rounded-full bg-brand" />
)}
</Button>
}
/>
}
/>
<TooltipContent side="bottom">{t(($) => $.header.filter_button)}</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" className="w-auto">
{/* Status */}
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<CircleDot className="size-3.5" />
<span className="flex-1">{t(($) => $.header.filter_status)}</span>
{statusFilters.length > 0 && (
<span className="text-xs text-primary font-medium">
{statusFilters.length}
</span>
)}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-auto min-w-48">
{ALL_STATUSES.map((s) => {
const checked = statusFilters.includes(s);
const count = counts.status.get(s) ?? 0;
return (
<DropdownMenuCheckboxItem
key={s}
checked={checked}
onCheckedChange={() => act.toggleStatusFilter(s)}
className={FILTER_ITEM_CLASS}
>
<HoverCheck checked={checked} />
<StatusIcon status={s} className="h-3.5 w-3.5" />
{STATUS_CONFIG[s].label}
{count > 0 && (
<span className="ml-auto text-xs text-muted-foreground">
{t(($) => $.header.issue_count, { count })}
</span>
)}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuSubContent>
</DropdownMenuSub>
{/* Priority */}
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<SignalHigh className="size-3.5" />
<span className="flex-1">{t(($) => $.header.filter_priority)}</span>
{priorityFilters.length > 0 && (
<span className="text-xs text-primary font-medium">
{priorityFilters.length}
</span>
)}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-auto min-w-44">
{PRIORITY_ORDER.map((p) => {
const checked = priorityFilters.includes(p);
const count = counts.priority.get(p) ?? 0;
return (
<DropdownMenuCheckboxItem
key={p}
checked={checked}
onCheckedChange={() => act.togglePriorityFilter(p)}
className={FILTER_ITEM_CLASS}
>
<HoverCheck checked={checked} />
<PriorityIcon priority={p} />
{PRIORITY_CONFIG[p].label}
{count > 0 && (
<span className="ml-auto text-xs text-muted-foreground">
{t(($) => $.header.issue_count, { count })}
</span>
)}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuSubContent>
</DropdownMenuSub>
{/* Reset */}
{hasActiveFilters && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={act.clearFilters}>
{t(($) => $.header.reset_filters)}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
{/* Display settings */}
<Popover>
<Tooltip>
<PopoverTrigger
render={
<TooltipTrigger
render={
<Button variant="outline" size="icon-sm" className="text-muted-foreground">
<SlidersHorizontal className="size-4" />
</Button>
}
/>
}
/>
<TooltipContent side="bottom">{t(($) => $.header.display_settings)}</TooltipContent>
</Tooltip>
<PopoverContent align="end" className="w-64 p-0">
{viewMode === "board" && (
<div className="border-b px-3 py-2.5">
<span className="text-xs font-medium text-muted-foreground">
{t(($) => $.header.grouping)}
</span>
<div className="mt-2">
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="outline"
size="sm"
className="w-full justify-between text-xs"
>
{groupingLabel}
<ChevronDown className="size-3 text-muted-foreground" />
</Button>
}
/>
<DropdownMenuContent align="start" className="w-auto">
{GROUPING_OPTIONS.map((opt) => (
<DropdownMenuItem
key={opt.value}
onClick={() => act.setGrouping(opt.value)}
>
{t(($) => $.header[GROUPING_LABEL_KEY[opt.value]])}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
)}
<div className="border-b px-3 py-2.5">
<span className="text-xs font-medium text-muted-foreground">
{t(($) => $.header.ordering)}
</span>
<div className="mt-2 flex items-center gap-1.5">
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="outline"
size="sm"
className="flex-1 justify-between text-xs"
>
{sortLabel}
<ChevronDown className="size-3 text-muted-foreground" />
</Button>
}
/>
<DropdownMenuContent align="start" className="w-auto">
{SORT_OPTIONS.map((opt) => (
<DropdownMenuItem
key={opt.value}
onClick={() => act.setSortBy(opt.value)}
>
{opt.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="outline"
size="icon-sm"
onClick={() =>
act.setSortDirection(
sortDirection === "asc" ? "desc" : "asc",
)
}
title={sortDirection === "asc" ? t(($) => $.header.ascending) : t(($) => $.header.descending)}
>
{sortDirection === "asc" ? (
<ArrowUp className="size-3.5" />
) : (
<ArrowDown className="size-3.5" />
)}
</Button>
</div>
</div>
<div className="px-3 py-2.5">
<span className="text-xs font-medium text-muted-foreground">
{t(($) => $.header.card_properties)}
</span>
<div className="mt-2 space-y-2">
{CARD_PROPERTY_OPTIONS.map((opt) => (
<label
key={opt.key}
className="flex cursor-pointer items-center justify-between"
>
<span className="text-sm">{opt.label}</span>
<Switch
size="sm"
checked={cardProperties[opt.key]}
onCheckedChange={() => act.toggleCardProperty(opt.key)}
/>
</label>
))}
</div>
</div>
</PopoverContent>
</Popover>
{/* View toggle */}
<DropdownMenu>
<Tooltip>
<DropdownMenuTrigger
render={
<TooltipTrigger
render={
<Button variant="outline" size="icon-sm" className="text-muted-foreground">
{viewMode === "board" ? (
<Columns3 className="size-4" />
) : viewMode === "swimlane" ? (
<Waves className="size-4" />
) : (
<List className="size-4" />
)}
</Button>
}
/>
}
/>
<TooltipContent side="bottom">
{viewMode === "board"
? t(($) => $.header.view_board)
: viewMode === "swimlane"
? t(($) => $.header.view_swimlane)
: t(($) => $.header.view_list)}
</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" className="w-auto">
<DropdownMenuGroup>
<DropdownMenuLabel>{t(($) => $.header.view_label)}</DropdownMenuLabel>
<DropdownMenuItem onClick={() => act.setViewMode("board")}>
<Columns3 />
{t(($) => $.header.view_board_short)}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => act.setViewMode("list")}>
<List />
{t(($) => $.header.view_list_short)}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => act.setViewMode("swimlane")}>
<Waves />
{t(($) => $.header.view_swimlane_short)}
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<IssueDisplayControls scopedIssues={allIssues} />
</div>
</div>
);

View File

@@ -248,11 +248,9 @@ export function MyIssuesPage() {
<span className="text-sm font-medium">{t(($) => $.page.breadcrumb)}</span>
</PageHeader>
{/* Header: scope tabs (left) + controls (right) */}
<MyIssuesHeader allIssues={myIssues} />
{/* Content: scrollable */}
<ViewStoreProvider store={myIssuesViewStore}>
{/* Header: scope tabs (left) + controls (right) */}
<MyIssuesHeader allIssues={myIssues} />
{myIssues.length === 0 ? (
<div className="flex flex-1 min-h-0 flex-col items-center justify-center gap-2 text-muted-foreground">
<ListTodo className="h-10 w-10 text-muted-foreground/40" />
@@ -295,6 +293,7 @@ export function MyIssuesPage() {
myIssuesScope={scope}
myIssuesFilter={filter}
sort={sort}
onMoveIssue={handleMoveIssue}
/>
)}
</div>

View File

@@ -244,6 +244,7 @@ function ProjectIssuesContent({
myIssuesFilter={filter}
sort={sort}
projectId={projectId}
onMoveIssue={handleMoveIssue}
/>
)}
{viewMode === "gantt" && <GanttView issues={filteredGanttIssues} />}