Compare commits

..

1 Commits

Author SHA1 Message Date
李冠辰
aa312c3789 feat(comments): allow selecting multiple attachments 2026-05-27 10:07:59 +08:00
7 changed files with 58 additions and 73 deletions

View File

@@ -265,6 +265,7 @@ function CommentRow({
const isOwn = entry.actor_type === "member" && entry.actor_id === currentUserId;
const canEditEntry = isOwn || (canModerate && entry.actor_type === "member");
const canDeleteEntry = isOwn || canModerate;
const isTemp = entry.id.startsWith("temp-");
const [confirmDelete, setConfirmDelete] = useState(false);
const startEdit = () => {
@@ -324,7 +325,7 @@ function CommentRow({
);
return (
<div className="py-3">
<div className={`py-3${isTemp ? " opacity-60" : ""}`}>
<div className="flex items-center gap-2.5">
<ActorAvatar actorType={entry.actor_type} actorId={entry.actor_id} size={24} enableHoverCard showStatusDot />
<span className="cursor-pointer text-sm font-medium">
@@ -343,11 +344,12 @@ function CommentRow({
</TooltipContent>
</Tooltip>
<div className="ml-auto flex items-center gap-0.5">
<QuickEmojiPicker
onSelect={(emoji) => onToggleReaction(entry.id, emoji)}
align="end"
/>
{!isTemp && (
<div className="ml-auto flex items-center gap-0.5">
<QuickEmojiPicker
onSelect={(emoji) => onToggleReaction(entry.id, emoji)}
align="end"
/>
<DropdownMenu>
<DropdownMenuTrigger
render={
@@ -389,7 +391,8 @@ function CommentRow({
onOpenChange={setConfirmDelete}
onConfirm={() => onDelete(entry.id)}
/>
</div>
</div>
)}
</div>
{editing ? (
@@ -448,14 +451,16 @@ function CommentRow({
<ReadonlyContent content={entry.content ?? ""} attachments={entry.attachments} />
</div>
<AttachmentList attachments={entry.attachments} content={entry.content} className="mt-1.5 pl-8" />
<ReactionBar
reactions={reactions}
currentUserId={currentUserId}
onToggle={(emoji) => onToggleReaction(entry.id, emoji)}
getActorName={getActorName}
hideAddButton={!isLongContent}
className="mt-1.5 pl-8"
/>
{!isTemp && (
<ReactionBar
reactions={reactions}
currentUserId={currentUserId}
onToggle={(emoji) => onToggleReaction(entry.id, emoji)}
getActorName={getActorName}
hideAddButton={!isLongContent}
className="mt-1.5 pl-8"
/>
)}
</>
)}
</div>
@@ -523,6 +528,7 @@ function CommentCardImpl({
// own their own outputs.
const canEditEntry = isOwn || (canModerate && entry.actor_type === "member");
const canDeleteEntry = isOwn || canModerate;
const isTemp = entry.id.startsWith("temp-");
const [confirmDelete, setConfirmDelete] = useState(false);
const startEdit = () => {
@@ -591,7 +597,7 @@ function CommentCardImpl({
const isHighlighted = highlightedCommentId === entry.id;
return (
<Card className={cn("!py-0 !gap-0 overflow-hidden transition-colors duration-700", isHighlighted && "ring-2 ring-brand/50 bg-brand/5")}>
<Card className={cn("!py-0 !gap-0 overflow-hidden transition-colors duration-700", isTemp && "opacity-60", isHighlighted && "ring-2 ring-brand/50 bg-brand/5")}>
{onCollapseResolved && (
<button
type="button"
@@ -641,7 +647,7 @@ function CommentCardImpl({
</span>
)}
{open && (
{open && !isTemp && (
<div className="ml-auto flex items-center gap-0.5">
<QuickEmojiPicker
onSelect={(emoji) => onToggleReaction(entry.id, emoji)}
@@ -772,14 +778,16 @@ function CommentCardImpl({
<ReadonlyContent content={entry.content ?? ""} attachments={entry.attachments} />
</div>
<AttachmentList attachments={entry.attachments} content={entry.content} className="mt-1.5 pl-10" />
<ReactionBar
reactions={reactions}
currentUserId={currentUserId}
onToggle={(emoji) => onToggleReaction(entry.id, emoji)}
getActorName={getActorName}
hideAddButton={!isLongContent}
className="mt-1.5 pl-10"
/>
{!isTemp && (
<ReactionBar
reactions={reactions}
currentUserId={currentUserId}
onToggle={(emoji) => onToggleReaction(entry.id, emoji)}
getActorName={getActorName}
hideAddButton={!isLongContent}
className="mt-1.5 pl-10"
/>
)}
</>
)}
</div>

View File

@@ -77,10 +77,10 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
.filter((a) => content.includes(a.url))
.map((a) => a.id);
setSubmitting(true);
editorRef.current?.clearContent();
setIsEmpty(true);
try {
await onSubmit(content, activeIds.length > 0 ? activeIds : undefined);
editorRef.current?.clearContent();
setIsEmpty(true);
setPendingAttachments([]);
clearDraft(draftKey);
} finally {

View File

@@ -96,10 +96,10 @@ function ReplyInput({
.filter((a) => content.includes(a.url))
.map((a) => a.id);
setSubmitting(true);
editorRef.current?.clearContent();
setIsEmpty(true);
try {
await onSubmit(content, activeIds.length > 0 ? activeIds : undefined);
editorRef.current?.clearContent();
setIsEmpty(true);
setPendingAttachments([]);
if (draftKey) clearDraft(draftKey);
} finally {

View File

@@ -1,6 +1,6 @@
"use client";
import { useEffect, useRef, useCallback, useMemo } from "react";
import { useEffect, useRef, useState, useCallback, useMemo } from "react";
import {
useQuery,
useQueryClient,
@@ -68,6 +68,8 @@ export function useIssueTimeline(issueId: string, userId?: string) {
const timeline = useMemo<TimelineEntry[]>(() => data ?? [], [data]);
const [submitting, setSubmitting] = useState(false);
// Stable mutation handles. TanStack v5 returns a fresh result wrapper from
// useMutation per render, but the inner mutateAsync / mutate functions are
// stable. Pull just those so the useCallback identities downstream don't
@@ -260,7 +262,8 @@ export function useIssueTimeline(issueId: string, userId?: string) {
const submitComment = useCallback(
async (content: string, attachmentIds?: string[]) => {
if (!content.trim() || !userId) return;
if (!content.trim() || submitting || !userId) return;
setSubmitting(true);
try {
await createComment({ content, attachmentIds });
} catch (err) {
@@ -269,9 +272,11 @@ export function useIssueTimeline(issueId: string, userId?: string) {
? err.message
: t(($) => $.comment.send_failed),
);
} finally {
setSubmitting(false);
}
},
[userId, createComment, t],
[userId, submitting, createComment, t],
);
const submitReply = useCallback(
@@ -422,6 +427,7 @@ export function useIssueTimeline(issueId: string, userId?: string) {
return {
timeline: optimisticTimeline,
loading,
submitting,
submitComment,
submitReply,
editComment,

View File

@@ -70,6 +70,9 @@ export function GitHubTab() {
const [disconnectTarget, setDisconnectTarget] = useState<string | null>(null);
const [disconnecting, setDisconnecting] = useState(false);
const githubRepoCount =
workspace?.repos?.filter((r) => /github\.com/i.test(r.url ?? "")).length ?? 0;
async function persistSetting(key: SettingsKey, next: boolean) {
if (!workspace || savingKey) return;
setSavingKey(key);
@@ -311,9 +314,14 @@ export function GitHubTab() {
<Card>
<CardContent>
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-sm font-medium">
{t(($) => $.github.repositories_shortcut_label)}
</p>
<div className="space-y-1">
<p className="text-sm font-medium">
{t(($) => $.github.repositories_shortcut_label)}
</p>
<p className="text-xs text-muted-foreground tabular-nums">
{githubRepoCount}
</p>
</div>
<Button
variant="outline"
size="sm"

View File

@@ -1002,6 +1002,7 @@ func runIssueCommentList(cmd *cobra.Command, args []string) error {
if err != nil {
return fmt.Errorf("list comments: %w", err)
}
fmt.Fprintf(os.Stderr, "Showing %d comments.\n", len(comments))
// The server emits the next-page cursor in headers when there is likely
// an older page. Surface it on stderr so an operator (and the agent
// prompt update that follows this PR) can scroll deeper without having

View File

@@ -1780,44 +1780,6 @@ func TestRunIssueCommentList_RecentStillLabelsCursorAsThread(t *testing.T) {
}
}
// TestRunIssueCommentList_DoesNotPrintShowingPreamble locks in the removal of
// the "Showing N comments." stderr preamble. The line was the only
// `list --output json` subcommand that emitted a human-readable count, which
// polluted stdout/stderr-merged consumers (agent harnesses, CI `2>&1`).
// Tracks GitHub issue #3303.
func TestRunIssueCommentList_DoesNotPrintShowingPreamble(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/issues/") && !strings.Contains(r.URL.Path, "/comments") {
json.NewEncoder(w).Encode(map[string]any{
"id": "issue-1",
"identifier": "MUL-1",
})
return
}
w.Write([]byte(`[{"id":"c1"},{"id":"c2"}]`))
}))
defer srv.Close()
t.Setenv("MULTICA_SERVER_URL", srv.URL)
t.Setenv("MULTICA_WORKSPACE_ID", "ws-1")
t.Setenv("MULTICA_TOKEN", "test-token")
stderr := captureStderr(t)
defer stderr.restore()
cmd := newIssueCommentListTestCmd()
if err := cmd.Flags().Set("output", "json"); err != nil {
t.Fatalf("set output: %v", err)
}
if err := runIssueCommentList(cmd, []string{"MUL-1"}); err != nil {
t.Fatalf("runIssueCommentList: %v", err)
}
if got := stderr.read(); strings.Contains(got, "Showing") {
t.Errorf("stderr must not contain a 'Showing ...' preamble, got: %q", got)
}
}
func TestValidIssueStatuses(t *testing.T) {
expected := map[string]bool{
"backlog": true,