Compare commits

...

5 Commits

Author SHA1 Message Date
李冠辰
aa312c3789 feat(comments): allow selecting multiple attachments 2026-05-27 10:07:59 +08:00
YOMXXX
7d24a8594a fix(comments): support edit-time attachment removal (#2965) 2026-05-27 09:48:59 +08:00
Naiyuan Qing
730fb61f4a fix(views): keep sort label centered in viewport during board scroll (#3325)
The "Board ordered by" overlay used absolute positioning inside a
scrollable container, causing it to drift with scroll content. Move
the overlay outside the scroll area into a non-scrolling wrapper so
it stays centered in the visible viewport.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-27 09:00:08 +08:00
Jiayuan Zhang
e55f050b84 fix(i18n): clean up zh-Hans translation inconsistencies (#3308)
- Normalize nav/page/section titles to plural English (Issues/Skills/Tasks) per conventions.zh.mdx rules for section titles
- Lowercase 'Issue' inside UI short phrase '我的 Issue' (UI short-phrase rule)
- Translate concept words in GitHub settings (Connection/Features/Repositories/Done)
- Translate 'Cloud Runtime' to '云端运行时' to match runtime→运行时 glossary

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-27 00:11:48 +08:00
Bohan Jiang
fa2a0e57ec feat(views): swimlane supports parent / project / assignee grouping (MUL-2711) (#3311)
* feat(views): swimlane supports parent / project / assignee grouping (MUL-2711)

The swimlane view was hard-coded to group by parent issue. This adds a
display dropdown so users can pick parent (default), project, or
assignee — analogous to how the board view exposes its grouping option.

- Generalise the lane builder in swimlane-view.tsx behind a `LaneGroup`
  abstraction (matcher + per-grouping `moveUpdates` payload) so the
  drag-end handler no longer branches on grouping. Cell ids gain a
  `<grouping>:<rawId>` prefix and lane sortable ids include the
  grouping so dnd-kit cannot collide entries from different groupings.
- Extend the view store with `swimlaneGrouping`, `swimlaneOrders` (one
  saved order per grouping), and a grouping-keyed `collapsedSwimlanes`.
  The persist `merge` defends against the old `string[]` shape so a
  pre-upgrade snapshot doesn't crash on first read.
- Wire `setSwimlaneGrouping` into the issues display popover next to
  the existing board grouping control. Add en / zh-Hans copy for the
  three swimlane buckets (Parent issue / Project / Assignee) and the
  two new pinned lanes (No project / Unassigned).
- Expand swimlane tests with parent / project / assignee smoke cases
  and update existing mocks to the new lane-id format. Add stable
  `useActorName` / `projectListOptions` mocks to avoid the
  set-state-in-effect loop that an unstable `getActorName` would
  trigger via the cells-rebuild memo.

Co-authored-by: multica-agent <github@multica.ai>

* feat(views): default swimlane grouping to assignee

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-26 22:23:14 +08:00
27 changed files with 1193 additions and 381 deletions

View File

@@ -12,9 +12,12 @@ import { defaultStorage } from "../../platform/storage";
export type ViewMode = "board" | "list" | "gantt" | "swimlane";
export type GanttZoom = "day" | "week" | "month";
export type IssueGrouping = "status" | "assignee";
export type SwimlaneGrouping = "parent" | "project" | "assignee";
export type SortField = "position" | "priority" | "start_date" | "due_date" | "created_at" | "title";
export type SortDirection = "asc" | "desc";
export const SWIMLANE_GROUPINGS: SwimlaneGrouping[] = ["parent", "project", "assignee"];
export interface CardProperties {
priority: boolean;
description: boolean;
@@ -79,8 +82,15 @@ export interface IssueViewState {
listCollapsedStatuses: IssueStatus[];
ganttZoom: GanttZoom;
ganttShowCompleted: boolean;
swimlaneOrder: string[];
collapsedSwimlanes: string[];
/** Active swimlane grouping dimension. */
swimlaneGrouping: SwimlaneGrouping;
/** Persisted lane order, keyed by grouping. Entries are raw lane ids
* (parent issue id, project id, or `<assigneeType>:<assigneeId>`). */
swimlaneOrders: Record<SwimlaneGrouping, string[]>;
/** Persisted collapsed lanes, keyed by grouping. Same id space as
* `swimlaneOrders`, plus the sentinel `"none"` for the pinned
* no-X lane and `"__orphans__"` for the parent-grouping fallback. */
collapsedSwimlanes: Record<SwimlaneGrouping, string[]>;
setViewMode: (mode: ViewMode) => void;
setGanttZoom: (zoom: GanttZoom) => void;
toggleGanttShowCompleted: () => void;
@@ -101,7 +111,10 @@ export interface IssueViewState {
setSortDirection: (dir: SortDirection) => void;
toggleCardProperty: (key: keyof CardProperties) => void;
toggleListCollapsed: (status: IssueStatus) => void;
setSwimlaneGrouping: (grouping: SwimlaneGrouping) => void;
/** Update the lane order for the currently active swimlane grouping. */
setSwimlaneOrder: (order: string[]) => void;
/** Toggle a lane key in the currently active swimlane grouping. */
toggleSwimlaneCollapsed: (key: string) => void;
}
@@ -132,8 +145,9 @@ export const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): Issue
listCollapsedStatuses: [],
ganttZoom: "week",
ganttShowCompleted: false,
swimlaneOrder: [],
collapsedSwimlanes: [],
swimlaneGrouping: "assignee",
swimlaneOrders: { parent: [], project: [], assignee: [] },
collapsedSwimlanes: { parent: [], project: [], assignee: [] },
setViewMode: (mode) => set({ viewMode: mode }),
setGanttZoom: (zoom) => set({ ganttZoom: zoom }),
@@ -239,13 +253,22 @@ export const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): Issue
? state.listCollapsedStatuses.filter((s) => s !== status)
: [...state.listCollapsedStatuses, status],
})),
setSwimlaneOrder: (order) => set({ swimlaneOrder: order }),
toggleSwimlaneCollapsed: (key) =>
setSwimlaneGrouping: (grouping) => set({ swimlaneGrouping: grouping }),
setSwimlaneOrder: (order) =>
set((state) => ({
collapsedSwimlanes: state.collapsedSwimlanes.includes(key)
? state.collapsedSwimlanes.filter((k) => k !== key)
: [...state.collapsedSwimlanes, key],
swimlaneOrders: { ...state.swimlaneOrders, [state.swimlaneGrouping]: order },
})),
toggleSwimlaneCollapsed: (key) =>
set((state) => {
const grouping = state.swimlaneGrouping;
const current = state.collapsedSwimlanes[grouping];
const next = current.includes(key)
? current.filter((k) => k !== key)
: [...current, key];
return {
collapsedSwimlanes: { ...state.collapsedSwimlanes, [grouping]: next },
};
}),
});
export const viewStorePersistOptions = (name: string) => ({
@@ -272,7 +295,8 @@ export const viewStorePersistOptions = (name: string) => ({
listCollapsedStatuses: state.listCollapsedStatuses,
ganttZoom: state.ganttZoom,
ganttShowCompleted: state.ganttShowCompleted,
swimlaneOrder: state.swimlaneOrder,
swimlaneGrouping: state.swimlaneGrouping,
swimlaneOrders: state.swimlaneOrders,
collapsedSwimlanes: state.collapsedSwimlanes,
}),
// Default Zustand merge is shallow, so a persisted `cardProperties` snapshot
@@ -293,6 +317,13 @@ export function mergeViewStatePersisted<T extends IssueViewState>(
current: T,
): T {
const p = (persisted ?? {}) as Partial<T>;
// `collapsedSwimlanes` changed shape from `string[]` to
// `Record<SwimlaneGrouping, string[]>`. A snapshot saved in the old
// shape would otherwise overwrite the default record with an array
// and crash on first read — fall back to the default when the
// persisted value isn't a plain object.
const isRecord = (v: unknown): v is Record<string, unknown> =>
v !== null && typeof v === "object" && !Array.isArray(v);
return {
...current,
...p,
@@ -300,6 +331,12 @@ export function mergeViewStatePersisted<T extends IssueViewState>(
...current.cardProperties,
...(p.cardProperties ?? {}),
},
swimlaneOrders: isRecord(p.swimlaneOrders)
? { ...current.swimlaneOrders, ...p.swimlaneOrders }
: current.swimlaneOrders,
collapsedSwimlanes: isRecord(p.collapsedSwimlanes)
? { ...current.collapsedSwimlanes, ...p.collapsedSwimlanes }
: current.collapsedSwimlanes,
};
}

View File

@@ -11,6 +11,7 @@ interface FileUploadButtonProps {
disabled?: boolean;
className?: string;
size?: "sm" | "default";
multiple?: boolean;
}
function FileUploadButton({
@@ -18,16 +19,17 @@ function FileUploadButton({
disabled,
className,
size = "default",
multiple = false,
}: FileUploadButtonProps) {
const { t } = useTranslation("ui");
const inputRef = useRef<HTMLInputElement>(null);
const attachLabel = t(($) => $.attach_file);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const files = Array.from(e.target.files ?? []);
if (files.length === 0) return;
e.target.value = "";
onSelect(file);
for (const file of files) onSelect(file);
};
const iconSize = size === "sm" ? "h-3.5 w-3.5" : "h-4 w-4";
@@ -52,6 +54,7 @@ function FileUploadButton({
<input
ref={inputRef}
type="file"
multiple={multiple}
className="hidden"
onChange={handleChange}
/>

View File

@@ -9,7 +9,7 @@
* that decision out of this file so this stays a single-purpose row UI.
*/
import { Download, Eye, FileText, Loader2 } from "lucide-react";
import { Download, Eye, FileText, Loader2, Trash2 } from "lucide-react";
import { useT } from "../i18n";
import { getPreviewKind } from "./utils/preview";
@@ -18,8 +18,10 @@ interface AttachmentCardChromeProps {
uploading?: boolean;
canPreview: boolean;
canDownload: boolean;
canDelete?: boolean;
onPreview: () => void;
onDownload: () => void;
onDelete?: () => void;
}
function AttachmentCardChrome({
@@ -27,8 +29,10 @@ function AttachmentCardChrome({
uploading,
canPreview,
canDownload,
canDelete,
onPreview,
onDownload,
onDelete,
}: AttachmentCardChromeProps) {
const { t } = useT("editor");
return (
@@ -78,6 +82,21 @@ function AttachmentCardChrome({
<Download className="size-3.5" />
</button>
)}
{!uploading && canDelete && onDelete && (
<button
type="button"
className="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
title={t(($) => $.attachment.remove)}
aria-label={t(($) => $.attachment.remove)}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
onDelete();
}}
>
<Trash2 className="size-3.5" />
</button>
)}
</div>
);
}
@@ -101,6 +120,8 @@ export interface AttachmentCardProps {
onPreview: () => void;
/** Pressed when the Download button is clicked. */
onDownload: () => void;
/** Optional remove button, used by editable comment/file-card surfaces. */
onDelete?: () => void;
}
export function AttachmentCard({
@@ -111,6 +132,7 @@ export function AttachmentCard({
uploading,
onPreview,
onDownload,
onDelete,
}: AttachmentCardProps) {
const kind = filename ? getPreviewKind(contentType, filename) : null;
// Media kinds (pdf/video/audio) are previewable from a URL alone — the
@@ -130,8 +152,10 @@ export function AttachmentCard({
uploading={uploading}
canPreview={canPreview}
canDownload={!!href}
canDelete={!!onDelete}
onPreview={onPreview}
onDownload={onDownload}
onDelete={onDelete}
/>
</div>
);

View File

@@ -190,6 +190,7 @@ export function Attachment({
filename={state.filename}
onPreview={openPreview}
onDownload={handleDownload}
onDelete={editable ? onDelete : undefined}
/>
{preview.modal}
</>
@@ -206,6 +207,7 @@ export function Attachment({
uploading={state.uploading}
onPreview={openPreview}
onDownload={handleDownload}
onDelete={editable ? onDelete : undefined}
/>
{preview.modal}
</>

View File

@@ -29,16 +29,19 @@ const FILE_CARD_MARKDOWN_RE = new RegExp(
// React NodeView — thin wrapper, all rendering lives in <Attachment>
// ---------------------------------------------------------------------------
export function FileCardView({ node }: NodeViewProps) {
export function FileCardView({ node, editor, deleteNode }: NodeViewProps) {
const href = (node.attrs.href as string) || "";
const filename = (node.attrs.filename as string) || "";
const uploading = node.attrs.uploading as boolean;
const editable = editor?.isEditable ?? false;
return (
<NodeViewWrapper as="div" className="file-card-node" data-type="fileCard">
<div contentEditable={false}>
<Attachment
attachment={{ kind: "url", url: href, filename, uploading }}
editable={editable}
onDelete={editable ? deleteNode : undefined}
/>
</div>
</NodeViewWrapper>

View File

@@ -26,7 +26,7 @@
* 80px placeholder and the toolbar pins itself open with all actions enabled.
*/
import { Download, ExternalLink, Maximize2 } from "lucide-react";
import { Download, ExternalLink, Maximize2, Trash2 } from "lucide-react";
import { cn } from "@multica/ui/lib/utils";
import { paths, useWorkspaceSlug } from "@multica/core/paths";
import { useT } from "../i18n";
@@ -42,6 +42,7 @@ interface HtmlAttachmentPreviewProps {
filename: string;
onPreview: () => void;
onDownload: () => void;
onDelete?: () => void;
}
export function HtmlAttachmentPreview({
@@ -49,6 +50,7 @@ export function HtmlAttachmentPreview({
filename,
onPreview,
onDownload,
onDelete,
}: HtmlAttachmentPreviewProps) {
const { t } = useT("editor");
// Subscribe to the same React Query cache key the body consumes so the
@@ -143,6 +145,21 @@ export function HtmlAttachmentPreview({
>
<Download className="h-3.5 w-3.5" />
</button>
{onDelete && (
<button
type="button"
className="flex h-6 w-6 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
title={t(($) => $.attachment.remove)}
aria-label={t(($) => $.attachment.remove)}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
onDelete();
}}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
</div>
);

View File

@@ -123,16 +123,7 @@ export const BoardColumn = memo(function BoardColumn({
</Tooltip>
</div>
</div>
<div
ref={setNodeRef}
className={`relative min-h-[200px] flex-1 space-y-2 overflow-y-auto rounded-lg p-1 transition-colors ${
isOver && sortLabel
? "ring-2 ring-brand/25 bg-accent/15"
: isOver
? "bg-accent/60"
: ""
}`}
>
<div className="relative min-h-[200px] flex-1 rounded-lg">
{isOver && sortLabel && (
<div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center rounded-lg bg-background/40">
<span className="rounded-md bg-popover px-2.5 py-1 text-xs font-medium text-popover-foreground shadow-sm border border-border">
@@ -140,17 +131,28 @@ export const BoardColumn = memo(function BoardColumn({
</span>
</div>
)}
<SortableContext items={issueIds} strategy={verticalListSortingStrategy}>
{resolvedIssues.map((issue) => (
<DraggableBoardCard key={issue.id} issue={issue} childProgress={childProgressMap?.get(issue.id)} disableSorting={!!sortLabel} />
))}
</SortableContext>
{issueIds.length === 0 && (
<p className="py-8 text-center text-xs text-muted-foreground">
{t(($) => $.board.empty_column)}
</p>
)}
{footer}
<div
ref={setNodeRef}
className={`absolute inset-0 space-y-2 overflow-y-auto rounded-lg p-1 transition-colors ${
isOver && sortLabel
? "ring-2 ring-brand/25 bg-accent/15"
: isOver
? "bg-accent/60"
: ""
}`}
>
<SortableContext items={issueIds} strategy={verticalListSortingStrategy}>
{resolvedIssues.map((issue) => (
<DraggableBoardCard key={issue.id} issue={issue} childProgress={childProgressMap?.get(issue.id)} disableSorting={!!sortLabel} />
))}
</SortableContext>
{issueIds.length === 0 && (
<p className="py-8 text-center text-xs text-muted-foreground">
{t(($) => $.board.empty_column)}
</p>
)}
{footer}
</div>
</div>
</div>
);

View File

@@ -121,7 +121,17 @@ function DeleteCommentDialog({
// Standalone attachment list — renders attachments not already in the markdown
// ---------------------------------------------------------------------------
export function AttachmentList({ attachments, content, className }: { attachments?: Attachment[]; content?: string; className?: string }) {
export function AttachmentList({
attachments,
content,
className,
onRemove,
}: {
attachments?: Attachment[];
content?: string;
className?: string;
onRemove?: (attachmentId: string) => void;
}) {
if (!attachments?.length) return null;
// Skip attachments whose URL is already referenced in the markdown content,
// and duplicates of the same file (same name/type/size) that are referenced.
@@ -151,6 +161,8 @@ export function AttachmentList({ attachments, content, className }: { attachment
<AttachmentRenderer
key={a.id}
attachment={{ kind: "record", attachment: a }}
editable={!!onRemove}
onDelete={onRemove ? () => onRemove(a.id) : undefined}
/>
))}
</div>
@@ -158,6 +170,34 @@ export function AttachmentList({ attachments, content, className }: { attachment
);
}
function collectActiveAttachmentIds(
content: string,
attachments: Attachment[],
retainedStandaloneIds?: Set<string> | null,
): string[] {
const ids = new Set<string>();
for (const attachment of attachments) {
if (content.includes(attachment.url)) ids.add(attachment.id);
}
for (const id of retainedStandaloneIds ?? []) ids.add(id);
return [...ids];
}
function sameIdSet(a: string[], b: string[]): boolean {
if (a.length !== b.length) return false;
const set = new Set(a);
return b.every((id) => set.has(id));
}
function initialStandaloneAttachmentIds(entry: TimelineEntry): Set<string> {
const content = entry.content ?? "";
return new Set(
(entry.attachments ?? [])
.filter((attachment) => !content.includes(attachment.url))
.map((attachment) => attachment.id),
);
}
// ---------------------------------------------------------------------------
// Single comment row (used for both parent and replies within the same Card)
// ---------------------------------------------------------------------------
@@ -192,6 +232,7 @@ function CommentRow({
// them to the comment (otherwise they'd remain orphaned at the issue level
// and disappear after refresh).
const [pendingAttachments, setPendingAttachments] = useState<Attachment[]>([]);
const [retainedStandaloneIds, setRetainedStandaloneIds] = useState<Set<string> | null>(null);
const editorAttachments = pendingAttachments.length > 0
? [...(entry.attachments ?? []), ...pendingAttachments]
: entry.attachments;
@@ -229,6 +270,7 @@ function CommentRow({
const startEdit = () => {
cancelledRef.current = false;
setRetainedStandaloneIds(initialStandaloneAttachmentIds(entry));
setEditing(true);
};
@@ -236,6 +278,7 @@ function CommentRow({
cancelledRef.current = true;
setEditing(false);
setPendingAttachments([]);
setRetainedStandaloneIds(null);
clearEditDraft(editDraftKey);
};
@@ -245,19 +288,25 @@ function CommentRow({
?.getMarkdown()
?.replace(/(\n\s*)+$/, "")
.trim();
if (!trimmed || trimmed === (entry.content ?? "").trim()) {
if (!trimmed) return;
const activeIds = collectActiveAttachmentIds(
trimmed,
[...(entry.attachments ?? []), ...pendingAttachments],
retainedStandaloneIds,
);
const attachmentsChanged = !sameIdSet(activeIds, (entry.attachments ?? []).map((a) => a.id));
if (trimmed === (entry.content ?? "").trim() && !attachmentsChanged) {
setEditing(false);
setPendingAttachments([]);
setRetainedStandaloneIds(null);
clearEditDraft(editDraftKey);
return;
}
const activeIds = pendingAttachments
.filter((a) => trimmed.includes(a.url))
.map((a) => a.id);
try {
await onEdit(entry.id, trimmed, activeIds.length > 0 ? activeIds : undefined);
await onEdit(entry.id, trimmed, activeIds);
setEditing(false);
setPendingAttachments([]);
setRetainedStandaloneIds(null);
clearEditDraft(editDraftKey);
} catch (err) {
toast.error(
@@ -271,6 +320,9 @@ function CommentRow({
const reactions = entry.reactions ?? [];
const contentText = entry.content ?? "";
const isLongContent = contentText.length > 500 || contentText.split("\n").length > 8;
const standaloneEditAttachments = (entry.attachments ?? []).filter((attachment) =>
retainedStandaloneIds?.has(attachment.id),
);
return (
<div className={`py-3${isTemp ? " opacity-60" : ""}`}>
@@ -366,10 +418,26 @@ function CommentRow({
/>
</div>
<div className="flex items-center justify-between mt-2">
<FileUploadButton
size="sm"
onSelect={(file) => editEditorRef.current?.uploadFile(file)}
/>
<div className="flex min-w-0 flex-1 flex-col gap-1">
{standaloneEditAttachments.length > 0 && (
<AttachmentList
attachments={standaloneEditAttachments}
className="max-w-full"
onRemove={(attachmentId) =>
setRetainedStandaloneIds((ids) => {
const next = new Set(ids ?? []);
next.delete(attachmentId);
return next;
})
}
/>
)}
<FileUploadButton
size="sm"
multiple
onSelect={(file) => editEditorRef.current?.uploadFile(file)}
/>
</div>
<div className="flex items-center gap-2">
<Button size="sm" variant="ghost" onClick={cancelEdit}>{t(($) => $.comment.cancel_edit)}</Button>
<Button size="sm" variant="outline" onClick={saveEdit}>{t(($) => $.comment.save_action)}</Button>
@@ -430,6 +498,7 @@ function CommentCardImpl({
const cancelledRef = useRef(false);
// Pending uploads from the root-comment edit pass — same rationale as CommentRow.
const [parentPendingAttachments, setParentPendingAttachments] = useState<Attachment[]>([]);
const [parentRetainedStandaloneIds, setParentRetainedStandaloneIds] = useState<Set<string> | null>(null);
const parentEditorAttachments = parentPendingAttachments.length > 0
? [...(entry.attachments ?? []), ...parentPendingAttachments]
: entry.attachments;
@@ -464,6 +533,7 @@ function CommentCardImpl({
const startEdit = () => {
cancelledRef.current = false;
setParentRetainedStandaloneIds(initialStandaloneAttachmentIds(entry));
setEditing(true);
};
@@ -471,6 +541,7 @@ function CommentCardImpl({
cancelledRef.current = true;
setEditing(false);
setParentPendingAttachments([]);
setParentRetainedStandaloneIds(null);
clearParentEditDraft(parentEditDraftKey);
};
@@ -480,19 +551,25 @@ function CommentCardImpl({
?.getMarkdown()
?.replace(/(\n\s*)+$/, "")
.trim();
if (!trimmed || trimmed === (entry.content ?? "").trim()) {
if (!trimmed) return;
const activeIds = collectActiveAttachmentIds(
trimmed,
[...(entry.attachments ?? []), ...parentPendingAttachments],
parentRetainedStandaloneIds,
);
const attachmentsChanged = !sameIdSet(activeIds, (entry.attachments ?? []).map((a) => a.id));
if (trimmed === (entry.content ?? "").trim() && !attachmentsChanged) {
setEditing(false);
setParentPendingAttachments([]);
setParentRetainedStandaloneIds(null);
clearParentEditDraft(parentEditDraftKey);
return;
}
const activeIds = parentPendingAttachments
.filter((a) => trimmed.includes(a.url))
.map((a) => a.id);
try {
await onEdit(entry.id, trimmed, activeIds.length > 0 ? activeIds : undefined);
await onEdit(entry.id, trimmed, activeIds);
setEditing(false);
setParentPendingAttachments([]);
setParentRetainedStandaloneIds(null);
clearParentEditDraft(parentEditDraftKey);
} catch (err) {
toast.error(
@@ -513,6 +590,9 @@ function CommentCardImpl({
const reactions = entry.reactions ?? [];
const contentText = entry.content ?? "";
const isLongContent = contentText.length > 500 || contentText.split("\n").length > 8;
const parentStandaloneEditAttachments = (entry.attachments ?? []).filter((attachment) =>
parentRetainedStandaloneIds?.has(attachment.id),
);
const isHighlighted = highlightedCommentId === entry.id;
@@ -665,10 +745,26 @@ function CommentCardImpl({
/>
</div>
<div className="flex items-center justify-between mt-2">
<FileUploadButton
size="sm"
onSelect={(file) => editEditorRef.current?.uploadFile(file)}
/>
<div className="flex min-w-0 flex-1 flex-col gap-1">
{parentStandaloneEditAttachments.length > 0 && (
<AttachmentList
attachments={parentStandaloneEditAttachments}
className="max-w-full"
onRemove={(attachmentId) =>
setParentRetainedStandaloneIds((ids) => {
const next = new Set(ids ?? []);
next.delete(attachmentId);
return next;
})
}
/>
)}
<FileUploadButton
size="sm"
multiple
onSelect={(file) => editEditorRef.current?.uploadFile(file)}
/>
</div>
<div className="flex items-center gap-2">
<Button size="sm" variant="ghost" onClick={cancelEdit}>{t(($) => $.comment.cancel_edit)}</Button>
<Button size="sm" variant="outline" onClick={saveEdit}>{t(($) => $.comment.save_action)}</Button>

View File

@@ -135,6 +135,7 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
</Tooltip>
<FileUploadButton
size="sm"
multiple
onSelect={(file) => editorRef.current?.uploadFile(file)}
/>
<SubmitButton

View File

@@ -59,11 +59,12 @@ import { LabelChip } from "../../labels/label-chip";
import {
SORT_OPTIONS,
GROUPING_OPTIONS,
SWIMLANE_GROUPINGS,
CARD_PROPERTY_OPTIONS,
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 type { SortField, IssueGrouping, SwimlaneGrouping, ViewMode } from "@multica/core/issues/stores/view-store";
import {
useIssuesScopeStore,
type IssuesScope,
@@ -602,6 +603,7 @@ export function IssueDisplayControls({
const sortBy = useViewStore((s) => s.sortBy);
const sortDirection = useViewStore((s) => s.sortDirection);
const grouping = useViewStore((s) => s.grouping);
const swimlaneGrouping = useViewStore((s) => s.swimlaneGrouping);
const cardProperties = useViewStore((s) => s.cardProperties);
const act = useViewStoreApi().getState();
@@ -631,6 +633,11 @@ export function IssueDisplayControls({
status: "group_status",
assignee: "group_assignee",
};
const SWIMLANE_GROUPING_LABEL_KEY: Record<SwimlaneGrouping, "group_parent" | "group_project" | "group_assignee"> = {
parent: "group_parent",
project: "group_project",
assignee: "group_assignee",
};
const CARD_PROPERTY_LABEL_KEY: Record<typeof CARD_PROPERTY_OPTIONS[number]["key"], "card_priority" | "card_description" | "card_assignee" | "card_start_date" | "card_due_date" | "card_project" | "card_labels" | "card_child_progress"> = {
priority: "card_priority",
description: "card_description",
@@ -643,6 +650,7 @@ export function IssueDisplayControls({
};
const sortLabel = t(($) => $.display[SORT_LABEL_KEY[sortBy]]);
const groupingLabel = t(($) => $.display[GROUPING_LABEL_KEY[grouping]]);
const swimlaneGroupingLabel = t(($) => $.display[SWIMLANE_GROUPING_LABEL_KEY[swimlaneGrouping]]);
return (
<div className="flex items-center gap-1">
@@ -911,6 +919,41 @@ export function IssueDisplayControls({
</div>
</div>
)}
{viewMode === "swimlane" && (
<div className="border-b px-3 py-2.5">
<span className="text-xs font-medium text-muted-foreground">
{t(($) => $.display.grouping_section)}
</span>
<div className="mt-2">
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="outline"
size="sm"
className="w-full justify-between text-xs"
>
{swimlaneGroupingLabel}
<ChevronDown className="size-3 text-muted-foreground" />
</Button>
}
/>
<DropdownMenuContent align="start" className="w-auto">
<DropdownMenuRadioGroup
value={swimlaneGrouping}
onValueChange={(v) => act.setSwimlaneGrouping(v as SwimlaneGrouping)}
>
{SWIMLANE_GROUPINGS.map((value) => (
<DropdownMenuRadioItem key={value} value={value}>
{t(($) => $.display[SWIMLANE_GROUPING_LABEL_KEY[value]])}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
)}
<div className="border-b px-3 py-2.5">
<span className="text-xs font-medium text-muted-foreground">

View File

@@ -166,6 +166,7 @@ function ReplyInput({
</Tooltip>
<FileUploadButton
size="sm"
multiple
onSelect={(file) => editorRef.current?.uploadFile(file)}
/>
<button

View File

@@ -27,6 +27,32 @@ vi.mock("@multica/core/paths", async () => {
};
});
// Stub backend-bound queries that the swimlane invokes for project /
// assignee groupings. The hook MUST return a stable reference each call
// — production `useActorName` wraps its returns in `useMemo`, and the
// swimlane feeds the result into a `useMemo(..., [getActorName, ...])`
// that then drives a `useEffect(setLocalCells, [cells])` chain. A fresh
// object per render therefore loops the effect indefinitely.
vi.mock("@multica/core/projects/queries", () => ({
projectListOptions: (_wsId: string) => ({
queryKey: ["projects", _wsId, "list"],
queryFn: () => Promise.resolve([]),
}),
}));
const { mockActorNameResult } = vi.hoisted(() => ({
mockActorNameResult: {
getActorName: (_type: string, _id: string) => "Mock Actor",
getActorInitials: () => "MA",
getActorAvatarUrl: () => null,
getMemberName: () => "Mock Member",
getAgentName: () => "Mock Agent",
getSquadName: () => "Mock Squad",
},
}));
vi.mock("@multica/core/workspace/hooks", () => ({
useActorName: () => mockActorNameResult,
}));
// Mock @multica/core/auth
const mockAuthUser = { id: "user-1", email: "test@test.com", name: "Test User" };
vi.mock("@multica/core/auth", () => ({
@@ -97,15 +123,22 @@ vi.mock("@multica/core/issues/mutations", async (importOriginal) => {
};
});
// Mock view store. `swimlaneOrder` is mutable on the captured object so
// tests can simulate persisted lane order and assert that
// `setSwimlaneOrder` was called by drag-end handlers.
type SwimlaneGroupingMock = "parent" | "project" | "assignee";
// Mock view store. The lane order and collapsed-lane fields are mutable
// records on the captured object so tests can simulate persisted state
// (per grouping) and assert that `setSwimlaneOrder` was called by drag-end
// handlers. The store actions operate on `swimlaneGrouping` — tests that
// flip grouping must set both `swimlaneGrouping` and the matching slice
// in `swimlaneOrders` / `collapsedSwimlanes`.
const mockViewState: {
sortBy: "position";
sortDirection: "asc";
cardProperties: Record<string, boolean>;
swimlaneOrder: string[];
collapsedSwimlanes: string[];
swimlaneGrouping: SwimlaneGroupingMock;
swimlaneOrders: Record<SwimlaneGroupingMock, string[]>;
collapsedSwimlanes: Record<SwimlaneGroupingMock, string[]>;
setSwimlaneGrouping: (g: SwimlaneGroupingMock) => void;
setSwimlaneOrder: (order: string[]) => void;
toggleSwimlaneCollapsed: (key: string) => void;
hideStatus: (s: string) => void;
@@ -114,8 +147,10 @@ const mockViewState: {
sortBy: "position",
sortDirection: "asc",
cardProperties: { priority: true, description: true, assignee: true, dueDate: true, project: true, childProgress: true, labels: true },
swimlaneOrder: [],
collapsedSwimlanes: [],
swimlaneGrouping: "parent",
swimlaneOrders: { parent: [], project: [], assignee: [] },
collapsedSwimlanes: { parent: [], project: [], assignee: [] },
setSwimlaneGrouping: vi.fn(),
setSwimlaneOrder: vi.fn(),
toggleSwimlaneCollapsed: vi.fn(),
hideStatus: vi.fn(),
@@ -269,8 +304,9 @@ function renderWithI18n(ui: React.ReactNode) {
describe("SwimLaneView", () => {
beforeEach(() => {
vi.clearAllMocks();
mockViewState.swimlaneOrder = [];
mockViewState.collapsedSwimlanes = [];
mockViewState.swimlaneGrouping = "parent";
mockViewState.swimlaneOrders = { parent: [], project: [], assignee: [] };
mockViewState.collapsedSwimlanes = { parent: [], project: [], assignee: [] };
useLoadMoreByStatusMock.mockImplementation(() => ({
total: 0,
loaded: 0,
@@ -727,8 +763,8 @@ describe("SwimLaneView", () => {
act(() => {
lastOnDragEnd({
active: { id: "lane:parent-1" },
over: { id: "lane:parent-2" },
active: { id: "lane:parent:parent-1" },
over: { id: "lane:parent:parent-2" },
});
});
@@ -736,7 +772,10 @@ describe("SwimLaneView", () => {
});
it("appends newly-visible parents to the persisted order on first reorder", () => {
mockViewState.swimlaneOrder = ["parent-1"];
mockViewState.swimlaneOrders = {
...mockViewState.swimlaneOrders,
parent: ["parent-1"],
};
renderWithI18n(
<SwimLaneView issues={multiParentIssues} onMoveIssue={vi.fn()} />,
@@ -744,8 +783,8 @@ describe("SwimLaneView", () => {
act(() => {
lastOnDragEnd({
active: { id: "lane:parent-1" },
over: { id: "lane:parent-2" },
active: { id: "lane:parent:parent-1" },
over: { id: "lane:parent:parent-2" },
});
});
@@ -753,7 +792,10 @@ describe("SwimLaneView", () => {
});
it("preserves persisted entries that aren't currently visible during a reorder", () => {
mockViewState.swimlaneOrder = ["filtered-a", "parent-1", "filtered-b", "parent-2"];
mockViewState.swimlaneOrders = {
...mockViewState.swimlaneOrders,
parent: ["filtered-a", "parent-1", "filtered-b", "parent-2"],
};
renderWithI18n(
<SwimLaneView issues={multiParentIssues} onMoveIssue={vi.fn()} />,
@@ -761,8 +803,8 @@ describe("SwimLaneView", () => {
act(() => {
lastOnDragEnd({
active: { id: "lane:parent-1" },
over: { id: "lane:parent-2" },
active: { id: "lane:parent:parent-1" },
over: { id: "lane:parent:parent-2" },
});
});
@@ -781,8 +823,8 @@ describe("SwimLaneView", () => {
act(() => {
lastOnDragEnd({
active: { id: "lane:parent-1" },
over: { id: "lane:parent-1" },
active: { id: "lane:parent:parent-1" },
over: { id: "lane:parent:parent-1" },
});
});
@@ -797,8 +839,8 @@ describe("SwimLaneView", () => {
act(() => {
lastOnDragEnd({
active: { id: "lane:parent-1" },
over: { id: "lane:parent-2" },
active: { id: "lane:parent:parent-1" },
over: { id: "lane:parent:parent-2" },
});
});
@@ -806,7 +848,10 @@ describe("SwimLaneView", () => {
});
it("renders parent lanes in stored swimlaneOrder when set", () => {
mockViewState.swimlaneOrder = ["parent-2", "parent-1"];
mockViewState.swimlaneOrders = {
...mockViewState.swimlaneOrders,
parent: ["parent-2", "parent-1"],
};
renderWithI18n(
<SwimLaneView issues={multiParentIssues} onMoveIssue={vi.fn()} />,
@@ -820,7 +865,10 @@ describe("SwimLaneView", () => {
});
it("keeps 'No parent' lane pinned at top regardless of stored order", () => {
mockViewState.swimlaneOrder = ["parent-2", "parent-1"];
mockViewState.swimlaneOrders = {
...mockViewState.swimlaneOrders,
parent: ["parent-2", "parent-1"],
};
renderWithI18n(
<SwimLaneView issues={multiParentIssues} onMoveIssue={vi.fn()} />,
@@ -836,7 +884,10 @@ describe("SwimLaneView", () => {
// ------------------------------------------------------------------
it("collapses a parent lane when its id is in stored collapsedSwimlanes", () => {
mockViewState.collapsedSwimlanes = ["parent-1"];
mockViewState.collapsedSwimlanes = {
...mockViewState.collapsedSwimlanes,
parent: ["parent-1"],
};
renderWithI18n(
<SwimLaneView issues={multiParentIssues} onMoveIssue={vi.fn()} />,
@@ -851,7 +902,10 @@ describe("SwimLaneView", () => {
});
it("collapses the 'No parent' lane when 'none' is in stored collapsedSwimlanes", () => {
mockViewState.collapsedSwimlanes = ["none"];
mockViewState.collapsedSwimlanes = {
...mockViewState.collapsedSwimlanes,
parent: ["none"],
};
renderWithI18n(
<SwimLaneView issues={mockIssues} onMoveIssue={vi.fn()} />,
@@ -887,4 +941,209 @@ describe("SwimLaneView", () => {
expect(mockToggleSwimlaneCollapsed).toHaveBeenCalledWith("none");
});
// ------------------------------------------------------------------
// Project grouping
// ------------------------------------------------------------------
const projectIssues: Issue[] = [
{
...mockIssues[0]!,
id: "issue-a",
identifier: "PROJ-100",
title: "Issue A",
project_id: "proj-1",
parent_issue_id: null,
status: "todo",
},
{
...mockIssues[0]!,
id: "issue-b",
identifier: "PROJ-101",
title: "Issue B",
project_id: "proj-2",
parent_issue_id: null,
status: "in_progress",
},
{
...mockIssues[0]!,
id: "issue-c",
identifier: "PROJ-102",
title: "Issue C",
project_id: null,
parent_issue_id: null,
status: "todo",
},
];
it("groups by project when swimlaneGrouping is 'project'", () => {
mockViewState.swimlaneGrouping = "project";
renderWithI18n(
<SwimLaneView issues={projectIssues} onMoveIssue={vi.fn()} />,
);
// No-project pinned lane is always present.
expect(screen.getAllByText("No project").length).toBeGreaterThanOrEqual(1);
// Both issue cards from real projects render — production fetches
// project titles from the API; in tests the mocked listProjects
// returns [] so the lane headers fall back to an empty title and
// we assert on card visibility, not lane title text.
expect(screen.getByText("Issue A")).toBeInTheDocument();
expect(screen.getByText("Issue B")).toBeInTheDocument();
expect(screen.getByText("Issue C")).toBeInTheDocument();
});
it("emits project_id when a card is dropped into a project lane", () => {
mockViewState.swimlaneGrouping = "project";
const mockOnMoveIssue = vi.fn();
renderWithI18n(
<SwimLaneView issues={projectIssues} onMoveIssue={mockOnMoveIssue} />,
);
// Drop "issue-c" (no project) into proj-1's todo cell.
const target = "swim:project:proj-1:todo";
act(() => {
lastOnDragOver({ active: { id: "issue-c" }, over: { id: target } });
});
act(() => {
lastOnDragEnd({ active: { id: "issue-c" }, over: { id: target } });
});
expect(mockOnMoveIssue).toHaveBeenCalledWith(
"issue-c",
expect.objectContaining({ project_id: "proj-1", status: "todo" }),
);
});
it("emits null project_id when a card is dropped into the 'No project' lane", () => {
mockViewState.swimlaneGrouping = "project";
const mockOnMoveIssue = vi.fn();
renderWithI18n(
<SwimLaneView issues={projectIssues} onMoveIssue={mockOnMoveIssue} />,
);
const target = "swim:project:none:in_review";
act(() => {
lastOnDragOver({ active: { id: "issue-a" }, over: { id: target } });
});
act(() => {
lastOnDragEnd({ active: { id: "issue-a" }, over: { id: target } });
});
expect(mockOnMoveIssue).toHaveBeenCalledWith(
"issue-a",
expect.objectContaining({ project_id: null, status: "in_review" }),
);
});
// ------------------------------------------------------------------
// Assignee grouping
// ------------------------------------------------------------------
const assigneeIssues: Issue[] = [
{
...mockIssues[0]!,
id: "issue-x",
identifier: "PROJ-200",
title: "Issue X",
assignee_type: "member",
assignee_id: "user-1",
parent_issue_id: null,
project_id: null,
status: "todo",
},
{
...mockIssues[0]!,
id: "issue-y",
identifier: "PROJ-201",
title: "Issue Y",
assignee_type: "agent",
assignee_id: "agent-1",
parent_issue_id: null,
project_id: null,
status: "in_progress",
},
{
...mockIssues[0]!,
id: "issue-z",
identifier: "PROJ-202",
title: "Issue Z",
assignee_type: null,
assignee_id: null,
parent_issue_id: null,
project_id: null,
status: "todo",
},
];
it("groups by assignee when swimlaneGrouping is 'assignee'", () => {
mockViewState.swimlaneGrouping = "assignee";
renderWithI18n(
<SwimLaneView issues={assigneeIssues} onMoveIssue={vi.fn()} />,
);
// Unassigned pinned lane is always rendered.
expect(screen.getAllByText("Unassigned").length).toBeGreaterThanOrEqual(1);
// Mock actor name fallback for both member and agent.
expect(screen.getAllByText("Mock Actor").length).toBeGreaterThanOrEqual(2);
expect(screen.getByText("Issue X")).toBeInTheDocument();
expect(screen.getByText("Issue Y")).toBeInTheDocument();
expect(screen.getByText("Issue Z")).toBeInTheDocument();
});
it("emits assignee_type + assignee_id when a card is dropped into an actor lane", () => {
mockViewState.swimlaneGrouping = "assignee";
const mockOnMoveIssue = vi.fn();
renderWithI18n(
<SwimLaneView issues={assigneeIssues} onMoveIssue={mockOnMoveIssue} />,
);
const target = "swim:assignee:member:user-1:in_review";
act(() => {
lastOnDragOver({ active: { id: "issue-z" }, over: { id: target } });
});
act(() => {
lastOnDragEnd({ active: { id: "issue-z" }, over: { id: target } });
});
expect(mockOnMoveIssue).toHaveBeenCalledWith(
"issue-z",
expect.objectContaining({
assignee_type: "member",
assignee_id: "user-1",
status: "in_review",
}),
);
});
it("emits null assignee when a card is dropped into the 'Unassigned' lane", () => {
mockViewState.swimlaneGrouping = "assignee";
const mockOnMoveIssue = vi.fn();
renderWithI18n(
<SwimLaneView issues={assigneeIssues} onMoveIssue={mockOnMoveIssue} />,
);
const target = "swim:assignee:none:done";
act(() => {
lastOnDragOver({ active: { id: "issue-x" }, over: { id: target } });
});
act(() => {
lastOnDragEnd({ active: { id: "issue-x" }, over: { id: target } });
});
expect(mockOnMoveIssue).toHaveBeenCalledWith(
"issue-x",
expect.objectContaining({
assignee_type: null,
assignee_id: null,
status: "done",
}),
);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -41,7 +41,8 @@
"preview_too_large": "File is too large to preview. Please download.",
"preview_unsupported": "This file type can't be previewed.",
"close": "Close",
"open_in_new_tab": "Open in new tab"
"open_in_new_tab": "Open in new tab",
"remove": "Remove attachment"
},
"link_hover": {
"copy_link": "Copy link",

View File

@@ -61,6 +61,8 @@
"descending_title": "Descending",
"group_status": "Status",
"group_assignee": "Assignee",
"group_parent": "Parent issue",
"group_project": "Project",
"sort_manual": "Manual",
"sort_priority": "Priority",
"sort_start_date": "Start date",
@@ -127,6 +129,8 @@
"swimlane": {
"no_parent": "No parent",
"other_parents": "Other parents",
"no_project": "No project",
"no_assignee": "Unassigned",
"open_parent": "Open parent issue",
"toggle_collapse": "Toggle lane"
},

View File

@@ -123,7 +123,7 @@
"inspector": {
"section_properties": "属性",
"section_details": "详情",
"section_skills": "skill",
"section_skills": "Skills",
"prop_runtime": "运行时",
"prop_model": "模型",
"prop_thinking": "思考",
@@ -186,9 +186,9 @@
},
"tabs": {
"activity": "动态",
"tasks": "task",
"tasks": "Tasks",
"instructions": "指令",
"skills": "skill",
"skills": "Skills",
"environment": "环境变量",
"custom_args": "自定义参数",
"discard_dialog_title": "放弃未保存的修改?",
@@ -229,7 +229,7 @@
"editor_placeholder": "写下这个智能体该做什么、关注什么、要避开什么…"
},
"skills_section": {
"label": "Skill",
"label": "Skills",
"placeholder": "从工作区添加 skill",
"selected_other": "已选 {{count}} 个 —— 点击修改",
"collapse": "收起",
@@ -324,7 +324,7 @@
"success_pct": "{{percent}}% 成功",
"avg_duration": "平均 {{value}}",
"failed_count": "{{count}} 次失败",
"source_issue": "issue",
"source_issue": "Issue",
"source_chat": "聊天",
"source_autopilot": "自动化",
"source_untracked": "未追踪",
@@ -332,7 +332,7 @@
"source_creating_issue": "正在创建 issue",
"source_chat_session": "聊天会话",
"source_autopilot_run": "自动化运行",
"issue_short_fallback": "issue {{prefix}}...",
"issue_short_fallback": "Issue {{prefix}}...",
"triggered_by": "触发来源",
"open_issue_aria": "打开 issue",
"open_issue_tooltip": "打开 issue",
@@ -366,7 +366,7 @@
"unavailable": "智能体不可用",
"detail_link": "详情 →",
"runtime_label": "运行时",
"skills_label": "skill",
"skills_label": "Skills",
"owner_label": "所有者",
"unknown_runtime": "未知运行时"
},

View File

@@ -41,7 +41,8 @@
"preview_too_large": "文件太大,无法在线预览,请下载查看。",
"preview_unsupported": "该文件类型暂不支持预览。",
"close": "关闭",
"open_in_new_tab": "在新标签页打开"
"open_in_new_tab": "在新标签页打开",
"remove": "移除附件"
},
"link_hover": {
"copy_link": "复制链接",
@@ -51,7 +52,7 @@
},
"mention": {
"group_users": "用户",
"group_issues": "issue",
"group_issues": "Issues",
"all_members": "所有成员",
"searching": "搜索中...",
"no_results": "无结果"

View File

@@ -1,6 +1,6 @@
{
"page": {
"breadcrumb_title": "issue",
"breadcrumb_title": "Issues",
"breadcrumb_workspace_fallback": "工作区",
"empty_title": "还没有 issue",
"empty_hint": "创建一个 issue 开始使用。",
@@ -59,6 +59,8 @@
"descending_title": "降序",
"group_status": "状态",
"group_assignee": "负责人",
"group_parent": "父级 issue",
"group_project": "项目",
"sort_manual": "手动",
"sort_priority": "优先级",
"sort_start_date": "开始日期",
@@ -125,6 +127,8 @@
"swimlane": {
"no_parent": "无父级",
"other_parents": "其他父级",
"no_project": "无项目",
"no_assignee": "未指派",
"open_parent": "打开父级 issue",
"toggle_collapse": "切换泳道"
},

View File

@@ -1,15 +1,15 @@
{
"nav": {
"inbox": "收件箱",
"my_issues": "我的 Issue",
"issues": "Issue",
"my_issues": "我的 issue",
"issues": "Issues",
"projects": "项目",
"autopilots": "自动化",
"agents": "智能体",
"squads": "小队",
"usage": "用量",
"runtimes": "运行时",
"skills": "Skill",
"skills": "Skills",
"settings": "设置"
},
"help": {

View File

@@ -282,7 +282,7 @@
"preview": {
"inbox_label": "收件箱",
"inbox_meta": "你的通知",
"issues_label": "issue",
"issues_label": "Issues",
"issues_meta": "共享任务面板",
"agents_label": "智能体",
"agents_meta": "你的 AI 队友",
@@ -292,7 +292,7 @@
"autopilot_meta": "定时自动化",
"runtimes_label": "运行时",
"runtimes_meta": "智能体跑的地方",
"skills_label": "skill",
"skills_label": "Skills",
"skills_meta": "可复用的剧本",
"more_label": "更多",
"more_meta": "等等"

View File

@@ -177,22 +177,22 @@
"create_agent": "创建智能体"
},
"cloud_runtime": {
"action": "Cloud Runtime",
"title": "Cloud Runtime",
"action": "云端运行时",
"title": "云端运行时",
"description": "创建托管云端节点,并查看它的 Fleet 状态。",
"create_title": "新节点",
"create_hint": "只需要填写名称、实例规格和磁盘大小,其余配置由 Fleet 默认处理。",
"nodes_title": "Fleet 节点",
"refresh": "刷新",
"nodes_empty": "还没有云端节点",
"nodes_failed": "Cloud Runtime 不可用",
"nodes_failed": "云端运行时不可用",
"nodes_failed_hint": "Fleet 未返回节点列表。",
"node_fallback_name": "云端节点",
"create": "创建节点",
"creating": "创建中...",
"cancel": "取消",
"toast_created": "Cloud Runtime 节点已创建",
"toast_create_failed": "创建 Cloud Runtime 节点失败",
"toast_created": "云端节点已创建",
"toast_create_failed": "创建云端节点失败",
"delete": "删除节点",
"delete_confirm": "确定要删除此云节点吗?此操作无法撤销。",
"toast_deleted": "云节点已删除",

View File

@@ -7,17 +7,17 @@
"commands": "命令",
"members": "成员",
"projects": "项目",
"issues": "issue",
"issues": "Issues",
"recent": "最近"
},
"pages": {
"inbox": "收件箱",
"my_issues": "我的 issue",
"issues": "issue",
"issues": "Issues",
"projects": "项目",
"agents": "智能体",
"runtimes": "运行时",
"skills": "skill",
"skills": "Skills",
"settings": "设置"
},
"commands": {

View File

@@ -179,11 +179,11 @@
"section_master": "启用 GitHub 功能",
"master_description_on": "关闭后,所有 GitHub 入口都会被隐藏,也不再产生新的副作用。后台已有数据不会被删除。团队不使用 GitHub在这里直接关掉即可。",
"master_description_off": "GitHub 功能已关闭。PR 侧栏、Co-authored-by trailer 与自动关联都已暂停。后台数据未删除。",
"section_connection": "Connection",
"section_connection": "连接",
"connection_title": "GitHub App",
"connection_description_prefix": "自动把 issue 关联到 Pull Request。当 PR 的分支、标题或正文中包含",
"connection_description_suffix": "并被合并时,对应的 issue 会自动转为",
"connection_description_done": "Done",
"connection_description_done": "已完成",
"connection_identifier_example": "MUL-123",
"connected_to": "已连接到 {{login}}",
"connect_github": "连接 GitHub",
@@ -204,7 +204,7 @@
"toast_open_failed": "打开 GitHub 安装页失败",
"toast_disconnect_failed": "断开 GitHub App 失败",
"toast_disconnected": "已断开 GitHub App",
"section_features": "Features",
"section_features": "功能",
"feature_pr_sidebar_label": "Pull Request 侧栏",
"feature_pr_sidebar_description": "在 Issue 详情侧栏中展示关联的 Pull Request。",
"feature_co_author_label": "Co-authored-by trailer",
@@ -212,8 +212,8 @@
"feature_co_author_description_suffix": "。",
"feature_auto_link_label": "Issue ↔ PR 自动关联",
"feature_auto_link_description": "根据 PR 标题、正文和分支名匹配 issue 编号并自动建立链接。",
"section_repositories": "Repositories",
"repositories_shortcut_label": "仓库 URL 仍在 Repositories 标签页中管理",
"section_repositories": "代码仓库",
"repositories_shortcut_label": "仓库 URL 仍在「代码仓库」标签页中管理",
"repositories_shortcut_link": "前往管理 →",
"toast_failed": "更新 GitHub 设置失败"
},

View File

@@ -1,6 +1,6 @@
{
"page": {
"title": "skill",
"title": "Skills",
"tagline": "工作区里任何智能体都能使用的指令。",
"learn_more": "了解更多 →",
"new_skill": "新建 skill",
@@ -198,7 +198,7 @@
"toast_created": "已创建 skill"
},
"url": {
"url_label": "skill URL",
"url_label": "Skill URL",
"supported_sources": "支持的来源",
"import": "导入",
"importing": "导入中...",

View File

@@ -1024,8 +1024,8 @@ func (h *Handler) UpdateComment(w http.ResponseWriter, r *http.Request) {
}
var req struct {
Content string `json:"content"`
AttachmentIDs []string `json:"attachment_ids"`
Content string `json:"content"`
AttachmentIDs *[]string `json:"attachment_ids"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
@@ -1036,9 +1036,14 @@ func (h *Handler) UpdateComment(w http.ResponseWriter, r *http.Request) {
return
}
attachmentIDs, ok := parseUUIDSliceOrBadRequest(w, req.AttachmentIDs, "attachment_ids")
if !ok {
return
var attachmentIDs []pgtype.UUID
replaceAttachments := req.AttachmentIDs != nil
if replaceAttachments {
var ok bool
attachmentIDs, ok = parseUUIDSliceOrBadRequest(w, *req.AttachmentIDs, "attachment_ids")
if !ok {
return
}
}
// NOTE: See CreateComment — Markdown is sanitized at render/edit time, not here.
@@ -1053,12 +1058,17 @@ func (h *Handler) UpdateComment(w http.ResponseWriter, r *http.Request) {
return
}
// Bind any newly uploaded attachments referenced in the edited content so
// they appear in the timeline's comment.attachments after refresh. Existing
// attachments already point at this comment via the upload flow; passing
// them again is a no-op at the SQL level.
if len(attachmentIDs) > 0 {
h.linkAttachmentsByIDs(r.Context(), comment.ID, existing.IssueID, attachmentIDs)
// Replace the comment attachment set when a modern client sends
// attachment_ids. Older clients omit the field; in that case preserve the
// existing attachment links rather than unlinking everything.
if replaceAttachments {
if err := h.Queries.ReplaceCommentAttachments(r.Context(), db.ReplaceCommentAttachmentsParams{
CommentID: comment.ID,
IssueID: existing.IssueID,
Column3: attachmentIDs,
}); err != nil {
slog.Error("failed to replace comment attachments", "error", err)
}
}
// Fetch reactions and attachments for the updated comment.

View File

@@ -447,3 +447,27 @@ func (q *Queries) ListAttachmentsByIssue(ctx context.Context, arg ListAttachment
}
return items, nil
}
const replaceCommentAttachments = `-- name: ReplaceCommentAttachments :exec
UPDATE attachment
SET comment_id = CASE
WHEN id = ANY($3::uuid[]) THEN $1
ELSE NULL
END
WHERE issue_id = $2
AND (
comment_id = $1
OR (comment_id IS NULL AND id = ANY($3::uuid[]))
)
`
type ReplaceCommentAttachmentsParams struct {
CommentID pgtype.UUID `json:"comment_id"`
IssueID pgtype.UUID `json:"issue_id"`
Column3 []pgtype.UUID `json:"column_3"`
}
func (q *Queries) ReplaceCommentAttachments(ctx context.Context, arg ReplaceCommentAttachmentsParams) error {
_, err := q.db.Exec(ctx, replaceCommentAttachments, arg.CommentID, arg.IssueID, arg.Column3)
return err
}

View File

@@ -44,6 +44,18 @@ WHERE issue_id = $2
AND comment_id IS NULL
AND id = ANY($3::uuid[]);
-- name: ReplaceCommentAttachments :exec
UPDATE attachment
SET comment_id = CASE
WHEN id = ANY($3::uuid[]) THEN $1
ELSE NULL
END
WHERE issue_id = $2
AND (
comment_id = $1
OR (comment_id IS NULL AND id = ANY($3::uuid[]))
);
-- name: LinkAttachmentsToChatMessage :exec
UPDATE attachment
SET chat_message_id = $1