feat: replies to comments
A comment can answer another one (replyTo), one level deep: answering a reply joins the same thread, and it must be on the same ticket. Whoever wrote the comment gets a new REPLIED notification, and the activity says "respondió a un comentario". A comment deleted while it has replies stays, without its text (deleted: true), so the thread still reads; it goes with its last reply and does not count in commentCount. The ticket page shows threads with their replies indented and a "Responder" box; REST, GraphQL and the MCP (add_comment, get_issue) take and show replyTo. Existing comments are unchanged. Docs, Word and PDF updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -39,6 +39,8 @@ export const issuePageEvents = eventGroup({
|
||||
body: string;
|
||||
/** Uploaded under the new comment once it exists. */
|
||||
files?: File[];
|
||||
/** Comment it answers (one level of replies). */
|
||||
replyTo?: string | null;
|
||||
}>(),
|
||||
watchRequested: type<{ issueId: string; watching: boolean }>(),
|
||||
trashRequested: type<{ issueId: string; trashed: boolean }>(),
|
||||
|
||||
@@ -92,10 +92,14 @@ export class IssueFacade {
|
||||
);
|
||||
}
|
||||
|
||||
/** `files` are attached under the new comment. */
|
||||
addComment(body: string, files: File[] = []): void {
|
||||
/** `files` are attached under the new comment; `replyTo` answers one. */
|
||||
addComment(
|
||||
body: string,
|
||||
files: File[] = [],
|
||||
replyTo: string | null = null,
|
||||
): void {
|
||||
this.withIssue((issueId) =>
|
||||
this.dispatch.commentAddRequested({ issueId, body, files }),
|
||||
this.dispatch.commentAddRequested({ issueId, body, files, replyTo }),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -460,7 +460,7 @@ export const IssueStore = signalStore(
|
||||
mergeMap(({ payload }) =>
|
||||
runCommand(
|
||||
comments
|
||||
.add(payload.issueId, payload.body)
|
||||
.add(payload.issueId, payload.body, payload.replyTo ?? null)
|
||||
.pipe(
|
||||
concatMap((comment) =>
|
||||
payload.files?.length
|
||||
@@ -474,7 +474,9 @@ export const IssueStore = signalStore(
|
||||
),
|
||||
payload.files?.length
|
||||
? 'Comentario y adjuntos añadidos'
|
||||
: 'Comentario añadido',
|
||||
: payload.replyTo
|
||||
? 'Respuesta añadida'
|
||||
: 'Comentario añadido',
|
||||
done,
|
||||
failed,
|
||||
),
|
||||
|
||||
@@ -53,6 +53,7 @@ export enum NotificationKind {
|
||||
QA_FAILED = 'QA_FAILED',
|
||||
COMMENTED = 'COMMENTED',
|
||||
MENTIONED = 'MENTIONED',
|
||||
REPLIED = 'REPLIED',
|
||||
WATCHED = 'WATCHED',
|
||||
ADDED_TO_PROJECT = 'ADDED_TO_PROJECT',
|
||||
REMINDER = 'REMINDER',
|
||||
|
||||
@@ -269,7 +269,12 @@ export interface Comment {
|
||||
id: string;
|
||||
issueId: string;
|
||||
author: UserRef;
|
||||
/** Empty on a deleted comment kept for its replies. */
|
||||
body: string;
|
||||
/** The top-level comment it answers; null for a top-level one. */
|
||||
replyTo: string | null;
|
||||
/** Deleted, but kept (without its text) while it has replies. */
|
||||
deleted: boolean;
|
||||
createdAt: string;
|
||||
editedAt: string | null;
|
||||
}
|
||||
|
||||
@@ -230,7 +230,11 @@ export abstract class SavedFiltersGateway {
|
||||
|
||||
export abstract class CommentsGateway {
|
||||
abstract list(issueId: string): Observable<Comment[]>;
|
||||
abstract add(issueId: string, body: string): Observable<CommandResult>;
|
||||
abstract add(
|
||||
issueId: string,
|
||||
body: string,
|
||||
replyTo?: string | null,
|
||||
): Observable<CommandResult>;
|
||||
abstract edit(commentId: string, body: string): Observable<CommandResult>;
|
||||
abstract remove(commentId: string): Observable<CommandResult>;
|
||||
}
|
||||
|
||||
@@ -42,9 +42,13 @@ export class GraphqlCommentsGateway extends CommentsGateway {
|
||||
.pipe(map((d) => d.issue.comments.items.map(toComment)));
|
||||
}
|
||||
|
||||
add(issueId: string, body: string): Observable<CommandResult> {
|
||||
add(
|
||||
issueId: string,
|
||||
body: string,
|
||||
replyTo: string | null = null,
|
||||
): Observable<CommandResult> {
|
||||
return this.gql
|
||||
.mutate(AddCommentDocument, { issueId, body })
|
||||
.mutate(AddCommentDocument, { issueId, body, replyTo })
|
||||
.pipe(map((d) => toCommandResult(d.addComment)));
|
||||
}
|
||||
|
||||
|
||||
@@ -141,6 +141,8 @@ fragment CommentFields on Comment {
|
||||
id
|
||||
issueId
|
||||
body
|
||||
replyTo
|
||||
deleted
|
||||
createdAt
|
||||
editedAt
|
||||
author {
|
||||
|
||||
@@ -212,6 +212,7 @@ export type NotificationKind =
|
||||
| 'MENTIONED'
|
||||
| 'QA_FAILED'
|
||||
| 'REMINDER'
|
||||
| 'REPLIED'
|
||||
| 'STATUS_CHANGED'
|
||||
| 'WATCHED';
|
||||
|
||||
@@ -554,6 +555,8 @@ export type CommentFieldsFragment = {
|
||||
id: string;
|
||||
issueId: string;
|
||||
body: string;
|
||||
replyTo: string | null;
|
||||
deleted: boolean;
|
||||
createdAt: string;
|
||||
editedAt: string | null;
|
||||
author: { id: string; name: string };
|
||||
@@ -875,6 +878,8 @@ export type IssueCommentsQuery = {
|
||||
id: string;
|
||||
issueId: string;
|
||||
body: string;
|
||||
replyTo: string | null;
|
||||
deleted: boolean;
|
||||
createdAt: string;
|
||||
editedAt: string | null;
|
||||
author: { id: string; name: string };
|
||||
@@ -886,6 +891,7 @@ export type IssueCommentsQuery = {
|
||||
export type AddCommentMutationVariables = Exact<{
|
||||
issueId: string;
|
||||
body: string;
|
||||
replyTo?: string | null | undefined;
|
||||
}>;
|
||||
|
||||
export type AddCommentMutation = {
|
||||
@@ -1853,6 +1859,8 @@ export type CommentChangedSubscription = {
|
||||
id: string;
|
||||
issueId: string;
|
||||
body: string;
|
||||
replyTo: string | null;
|
||||
deleted: boolean;
|
||||
createdAt: string;
|
||||
editedAt: string | null;
|
||||
author: { id: string; name: string };
|
||||
@@ -2698,6 +2706,8 @@ export const CommentFieldsFragmentDoc = {
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'issueId' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'body' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'replyTo' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'deleted' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'editedAt' } },
|
||||
{
|
||||
@@ -5477,6 +5487,8 @@ export const IssueCommentsDocument = {
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'issueId' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'body' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'replyTo' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'deleted' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'editedAt' } },
|
||||
{
|
||||
@@ -5527,6 +5539,14 @@ export const AddCommentDocument = {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'replyTo' },
|
||||
},
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
@@ -5551,6 +5571,14 @@ export const AddCommentDocument = {
|
||||
name: { kind: 'Name', value: 'body' },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'replyTo' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'replyTo' },
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
@@ -11232,6 +11260,8 @@ export const CommentChangedDocument = {
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'issueId' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'body' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'replyTo' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'deleted' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'editedAt' } },
|
||||
{
|
||||
|
||||
@@ -112,8 +112,8 @@ query IssueComments($idOrKey: String!) {
|
||||
}
|
||||
}
|
||||
|
||||
mutation AddComment($issueId: ID!, $body: String!) {
|
||||
addComment(issueId: $issueId, body: $body) {
|
||||
mutation AddComment($issueId: ID!, $body: String!, $replyTo: ID) {
|
||||
addComment(issueId: $issueId, body: $body, replyTo: $replyTo) {
|
||||
...CommandResultFields
|
||||
}
|
||||
}
|
||||
|
||||
@@ -531,3 +531,32 @@ a.ts-key {
|
||||
border-radius: 0.5rem;
|
||||
background: color-mix(in srgb, var(--ts-c-red, #dc2626) 12%, transparent);
|
||||
}
|
||||
|
||||
// One level of replies under each comment.
|
||||
.ts-replies {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
margin: -0.25rem 0 0.5rem 2.75rem;
|
||||
padding-left: 0.875rem;
|
||||
border-left: 2px solid var(--ts-line);
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.ts-comment__gone {
|
||||
margin: 0;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.ts-reply__open {
|
||||
justify-self: start;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.ts-reply__actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { NgTemplateOutlet } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
@@ -71,6 +72,7 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
selector: 'ts-issue-page',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
NgTemplateOutlet,
|
||||
RouterLink,
|
||||
FormField,
|
||||
AttachmentItem,
|
||||
@@ -512,22 +514,24 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
<article class="ts-block">
|
||||
<h2 class="ts-section">
|
||||
Comentarios
|
||||
<span class="ts-mono">{{ facade.comments().length }}</span>
|
||||
<span class="ts-mono">{{ commentCount() }}</span>
|
||||
</h2>
|
||||
@for (comment of facade.comments(); track comment.id) {
|
||||
<ng-template #commentView let-comment>
|
||||
<div class="ts-comment">
|
||||
<span class="ts-comment__avatar" aria-hidden="true">{{
|
||||
initialsOf(comment.author.name)
|
||||
}}</span>
|
||||
<div class="ts-comment__body">
|
||||
<div class="ts-comment__head">
|
||||
<strong>{{ comment.author.name }}</strong>
|
||||
<strong>{{
|
||||
comment.deleted ? '—' : comment.author.name
|
||||
}}</strong>
|
||||
<span class="ts-comment__when"
|
||||
>{{ ago(comment.createdAt)
|
||||
}}{{ comment.editedAt ? ' · editado' : '' }}</span
|
||||
>
|
||||
<span class="ts-spacer"></span>
|
||||
@if (comment.author.id === facade.me()) {
|
||||
@if (!comment.deleted && comment.author.id === facade.me()) {
|
||||
<pi-icon-button
|
||||
icon="edit"
|
||||
color="grey"
|
||||
@@ -537,7 +541,8 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
/>
|
||||
}
|
||||
@if (
|
||||
comment.author.id === facade.me() || project.canManage()
|
||||
!comment.deleted &&
|
||||
(comment.author.id === facade.me() || project.canManage())
|
||||
) {
|
||||
<pi-icon-button
|
||||
icon="trash"
|
||||
@@ -548,6 +553,9 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
@if (comment.deleted) {
|
||||
<p class="ts-muted ts-comment__gone">Comentario borrado</p>
|
||||
} @else {
|
||||
<p class="ts-prewrap">
|
||||
@for (part of segments(comment.body); track $index) {
|
||||
@if ('mention' in part) {
|
||||
@@ -561,7 +569,8 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
}
|
||||
}
|
||||
</p>
|
||||
@if (filesOf(comment).length) {
|
||||
}
|
||||
@if (!comment.deleted && filesOf(comment).length) {
|
||||
<div class="ts-attachments">
|
||||
@for (file of filesOf(comment); track file.id) {
|
||||
<ts-attachment
|
||||
@@ -573,6 +582,57 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
@for (thread of threads(); track thread.comment.id) {
|
||||
<ng-container
|
||||
*ngTemplateOutlet="commentView; context: { $implicit: thread.comment }"
|
||||
/>
|
||||
<div class="ts-replies">
|
||||
@for (reply of thread.replies; track reply.id) {
|
||||
<ng-container
|
||||
*ngTemplateOutlet="commentView; context: { $implicit: reply }"
|
||||
/>
|
||||
}
|
||||
@if (replyingTo() === thread.comment.id) {
|
||||
<form
|
||||
class="ts-form ts-reply"
|
||||
(submit)="$event.preventDefault(); reply(thread.comment)"
|
||||
novalidate
|
||||
>
|
||||
<pi-textarea
|
||||
[placeholder]="'Responde a ' + thread.comment.author.name + '…'"
|
||||
[(value)]="replyBody"
|
||||
/>
|
||||
<div class="ts-reply__actions">
|
||||
<pi-action-button
|
||||
color="primary"
|
||||
text="Responder"
|
||||
[disabled]="!replyBody().trim()"
|
||||
(execute)="reply(thread.comment)"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="ts-plain-link"
|
||||
(click)="replyingTo.set(null)"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
} @else if (project.canContribute() && !thread.comment.deleted) {
|
||||
<button
|
||||
type="button"
|
||||
class="ts-plain-link ts-reply__open"
|
||||
(click)="startReply(thread.comment)"
|
||||
>
|
||||
Responder{{
|
||||
thread.replies.length
|
||||
? ' (' + thread.replies.length + ')'
|
||||
: ''
|
||||
}}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (project.canContribute()) {
|
||||
<form
|
||||
@@ -934,6 +994,25 @@ export class IssuePage {
|
||||
protected readonly linkType = signal<string | null>(IssueLinkType.RELATES_TO);
|
||||
protected readonly linkTarget = signal('');
|
||||
protected readonly commentBody = signal('');
|
||||
/** The comment being answered, and the answer being written. */
|
||||
protected readonly replyingTo = signal<string | null>(null);
|
||||
protected readonly replyBody = signal('');
|
||||
/** Top-level comments, each with its replies (oldest first). */
|
||||
protected readonly threads = computed(() => {
|
||||
const comments = this.facade.comments();
|
||||
const ids = new Set(comments.map((c) => c.id));
|
||||
const replies = new Map<string, Comment[]>();
|
||||
for (const c of comments)
|
||||
if (c.replyTo && ids.has(c.replyTo))
|
||||
replies.set(c.replyTo, [...(replies.get(c.replyTo) ?? []), c]);
|
||||
// A reply whose comment did not load stands on its own.
|
||||
return comments
|
||||
.filter((c) => !c.replyTo || !ids.has(c.replyTo))
|
||||
.map((comment) => ({ comment, replies: replies.get(comment.id) ?? [] }));
|
||||
});
|
||||
protected readonly commentCount = computed(
|
||||
() => this.facade.comments().filter((c) => !c.deleted).length,
|
||||
);
|
||||
protected readonly commentFiles = signal<File[]>([]);
|
||||
protected readonly segments = mentionSegments;
|
||||
/** Members matching what is typed after an `@` at the end of the comment. */
|
||||
@@ -1126,6 +1205,19 @@ export class IssuePage {
|
||||
this.commentFiles.set([]);
|
||||
}
|
||||
|
||||
protected startReply(comment: Comment): void {
|
||||
this.replyBody.set('');
|
||||
this.replyingTo.set(comment.id);
|
||||
}
|
||||
|
||||
protected reply(comment: Comment): void {
|
||||
const body = this.replyBody().trim();
|
||||
if (!body) return;
|
||||
this.facade.addComment(body, [], comment.id);
|
||||
this.replyBody.set('');
|
||||
this.replyingTo.set(null);
|
||||
}
|
||||
|
||||
protected mention(user: { id: string; name: string }): void {
|
||||
this.commentBody.update((text) => insertMention(text, user));
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ const ICON: Record<NotificationKind, string> = {
|
||||
[NotificationKind.QA_FAILED]: 'exclamation',
|
||||
[NotificationKind.COMMENTED]: 'chat',
|
||||
[NotificationKind.MENTIONED]: 'chat',
|
||||
[NotificationKind.REPLIED]: 'chat',
|
||||
[NotificationKind.WATCHED]: 'focus',
|
||||
[NotificationKind.ADDED_TO_PROJECT]: 'briefcase',
|
||||
[NotificationKind.REMINDER]: 'calendar',
|
||||
|
||||
Reference in New Issue
Block a user