feat: conversations on a ticket are settled, and you see who is typing

A top-level comment and its replies can be resolved by whoever can
change the ticket (CommentThreadResolvedEvent), with an optional
conclusion added as a reply marked as a decision, so it shows above the
ticket. A settled conversation folds to one line (topic, who settled it
and the conclusion) and can be unfolded or reopened; the header counts
the open ones, and finishing the ticket with one open asks first.

«Ana está escribiendo…» shows in the conversation being answered, at
once: a `typing` mutation publishes to a Redis channel per ticket
(`typing` subscription), never stored. In replies, Enter sends and
Shift+Enter breaks the line, except inside lists and code.

API: resolveThread / PUT-DELETE /api/comments/{id}/resolution, Comment
.resolved/resolvedBy/resolvedAt, typing mutation and subscription. MCP:
resolve_thread.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 22:35:46 +02:00
parent c0b0417b6d
commit 199b60e4ce
51 changed files with 1965 additions and 126 deletions

View File

@@ -81,6 +81,11 @@ export const issuePageEvents = eventGroup({
commentEditRequested: type<{ commentId: string; body: string }>(),
reactRequested: type<{ commentId: string; emoji: string; on: boolean }>(),
decisionRequested: type<{ commentId: string; on: boolean }>(),
threadResolveRequested: type<{
commentId: string;
resolved: boolean;
conclusion: string | null;
}>(),
voteRequested: type<{ issueId: string; on: boolean }>(),
/** An image pasted into a text was uploaded (it joins the ticket's files). */
imagePasted: type<{ issueId: string }>(),

View File

@@ -1,12 +1,14 @@
import { type } from '@ngrx/signals';
import { eventGroup } from '@ngrx/signals/events';
import { Presence } from '@task-stream/web/tracker/domain';
import { Presence, Typing } from '@task-stream/web/tracker/domain';
export const presencePageEvents = eventGroup({
source: 'Issue Presence',
events: {
opened: type<string>(),
editingChanged: type<boolean>(),
/** I typed in an answer to this conversation (null: a new comment). */
typed: type<string | null>(),
left: type<void>(),
},
});
@@ -15,5 +17,9 @@ export const presenceApiEvents = eventGroup({
source: 'Presence API',
events: {
seen: type<Presence[]>(),
/** Someone else is writing. */
typing: type<Typing>(),
/** …and stopped a while ago (the `at` it was last seen). */
typingStopped: type<{ userId: string; at: string }>(),
},
});

View File

@@ -231,6 +231,22 @@ export class IssueFacade {
this.dispatch.reactRequested({ commentId, emoji, on });
}
/**
* Settles a conversation (or reopens it); a conclusion is added as a
* reply marked as a decision.
*/
resolveThread(
commentId: string,
resolved: boolean,
conclusion: string | null = null,
): void {
this.dispatch.threadResolveRequested({
commentId,
resolved,
conclusion: conclusion?.trim() || null,
});
}
/** Marks a comment as a decision of the ticket (`on`) or unmarks it. */
markDecision(commentId: string, on: boolean): void {
this.dispatch.decisionRequested({ commentId, on });

View File

@@ -11,6 +11,8 @@ export class PresenceFacade {
/** The others, the ones editing first. */
readonly others = this.store.others;
/** Who else is writing now, and where (`threadId` null: a new comment). */
readonly typists = this.store.typists;
/** I am on this ticket: beats until the page closes. */
open(issueId: string): void {
@@ -20,6 +22,11 @@ export class PresenceFacade {
setEditing(editing: boolean): void {
this.dispatch.editingChanged(editing);
}
/** I am writing an answer to `threadId` (null: a new comment). */
typed(threadId: string | null): void {
this.dispatch.typed(threadId);
}
}
/** Store + facade of a ticket's presence, for the ticket's page. */

View File

@@ -479,6 +479,26 @@ export const IssueStore = signalStore(
),
),
),
events
.on(issuePageEvents.threadResolveRequested)
.pipe(
mergeMap(({ payload }) =>
runCommand(
comments.resolveThread(
payload.commentId,
payload.resolved,
payload.conclusion,
),
!payload.resolved
? 'Conversación reabierta'
: payload.conclusion
? 'Resuelta: la conclusión queda como decisión'
: 'Conversación resuelta',
done,
failed,
),
),
),
events.on(issuePageEvents.reactRequested).pipe(
mergeMap(({ payload }) =>
comments.react(payload.commentId, payload.emoji, payload.on).pipe(

View File

@@ -6,18 +6,26 @@ import {
withEventHandlers,
withReducer,
} from '@ngrx/signals/events';
import { Presence, PresenceGateway } from '@task-stream/web/tracker/domain';
import { AuthFacade } from '@task-stream/web/identity/application';
import {
Presence,
PresenceGateway,
Typing,
} from '@task-stream/web/tracker/domain';
import {
catchError,
combineLatest,
distinctUntilChanged,
EMPTY,
filter,
groupBy,
map,
mergeMap,
pairwise,
startWith,
switchMap,
takeUntil,
throttleTime,
timer,
} from 'rxjs';
import {
@@ -27,10 +35,16 @@ import {
/** A heartbeat every so often; the API forgets a page after 30 s without one. */
export const PRESENCE_BEAT_MS = 10_000;
/** While typing, I say so at most this often… */
export const TYPING_EVERY_MS = 3_000;
/** …and someone silent this long has stopped. */
export const TYPING_GONE_MS = 6_000;
interface PresenceState {
issueId: string | null;
others: Presence[];
/** Who else is writing now, one entry each. */
typists: Typing[];
}
/**
@@ -39,17 +53,75 @@ interface PresenceState {
* to the ticket's page.
*/
export const PresenceStore = signalStore(
withState<PresenceState>({ issueId: null, others: [] }),
withState<PresenceState>({ issueId: null, others: [], typists: [] }),
withReducer(
on(presencePageEvents.opened, ({ payload }) => ({
issueId: payload,
others: [],
typists: [],
})),
on(presenceApiEvents.seen, ({ payload }) => ({ others: payload })),
on(presencePageEvents.left, () => ({ issueId: null, others: [] })),
on(presenceApiEvents.typing, ({ payload }, state) => ({
typists: [
...state.typists.filter((t) => t.user.id !== payload.user.id),
payload,
],
})),
on(presenceApiEvents.typingStopped, ({ payload }, state) => ({
typists: state.typists.filter(
(t) => !(t.user.id === payload.userId && t.at === payload.at),
),
})),
on(presencePageEvents.left, () => ({
issueId: null,
others: [],
typists: [],
})),
),
withEventHandlers(
(store, events = inject(Events), presence = inject(PresenceGateway)) => ({
(
store,
events = inject(Events),
presence = inject(PresenceGateway),
auth = inject(AuthFacade),
) => ({
// Each answer (or the new comment) tells the others at most every 3 s.
typed$: events.on(presencePageEvents.typed).pipe(
map(({ payload }) => payload),
groupBy((threadId) => threadId ?? ''),
mergeMap((thread$) => thread$.pipe(throttleTime(TYPING_EVERY_MS))),
mergeMap((threadId) => {
const issueId = store.issueId();
// Telling is a nicety: nothing to say if it fails.
return issueId
? presence.typing(issueId, threadId).pipe(catchError(() => EMPTY))
: EMPTY;
}),
switchMap(() => EMPTY),
),
typists$: events.on(presencePageEvents.opened).pipe(
switchMap(({ payload: issueId }) =>
presence.typists(issueId).pipe(
filter((typing) => typing.user.id !== auth.user()?.id),
map((typing) => presenceApiEvents.typing(typing)),
catchError(() => EMPTY),
takeUntil(events.on(presencePageEvents.left)),
),
),
),
// Silent for a while: stopped (unless a newer one came meanwhile).
typingStopped$: events.on(presenceApiEvents.typing).pipe(
mergeMap(({ payload }) =>
timer(TYPING_GONE_MS).pipe(
map(() =>
presenceApiEvents.typingStopped({
userId: payload.user.id,
at: payload.at,
}),
),
),
),
),
beat$: events.on(presencePageEvents.opened).pipe(
switchMap(({ payload: issueId }) =>
combineLatest([

View File

@@ -202,6 +202,14 @@ export interface Issue extends IssueSummary {
}
/** Someone else on a ticket's page right now. */
/** Someone writing on a ticket right now. */
export interface Typing {
user: UserRef;
/** The conversation being answered; null: a new comment. */
threadId: string | null;
at: string;
}
export interface Presence {
user: UserRef;
/** Editing the ticket or writing a comment. */
@@ -342,6 +350,10 @@ export interface Comment {
decision: boolean;
/** Who marked it as a decision (null when it is not one). */
decidedBy: UserRef | null;
/** A conversation (top-level comment and its replies) that was settled. */
resolved: boolean;
/** Who settled it (null while open). */
resolvedBy: UserRef | null;
createdAt: string;
editedAt: string | null;
}

View File

@@ -19,6 +19,7 @@ import {
AlertIntegration,
Comment,
Decision,
Typing,
Epic,
GitIntegration,
GitRepository,
@@ -313,6 +314,15 @@ export abstract class CommentsGateway {
on: boolean,
): Observable<CommandResult>;
abstract remove(commentId: string): Observable<CommandResult>;
/**
* Settles a conversation (or reopens it); a conclusion is added as a
* reply marked as a decision.
*/
abstract resolveThread(
commentId: string,
resolved: boolean,
conclusion: string | null,
): Observable<CommandResult>;
/** Marks it as a decision of its ticket (`on`) or unmarks it. */
abstract markDecision(
commentId: string,
@@ -496,4 +506,8 @@ export abstract class TrackerRealtimeGateway {
export abstract class PresenceGateway {
abstract touch(issueId: string, editing: boolean): Observable<Presence[]>;
abstract leave(issueId: string): Observable<void>;
/** I am writing (an answer to `threadId`, or a new comment). */
abstract typing(issueId: string, threadId: string | null): Observable<void>;
/** Who is writing on the ticket, as they type (me included). */
abstract typists(issueId: string): Observable<Typing>;
}

View File

@@ -24,6 +24,7 @@ import {
IssueCommentsDocument,
ReactToCommentDocument,
MarkDecisionDocument,
ResolveThreadDocument,
PublishReleaseDocument,
ReleaseBurnupDocument,
ReleaseNotesDraftDocument,
@@ -77,6 +78,16 @@ export class GraphqlCommentsGateway extends CommentsGateway {
.pipe(map((d) => toCommandResult(d.deleteComment)));
}
resolveThread(
commentId: string,
resolved: boolean,
conclusion: string | null,
): Observable<CommandResult> {
return this.gql
.mutate(ResolveThreadDocument, { commentId, resolved, conclusion })
.pipe(map((d) => toCommandResult(d.resolveThread)));
}
markDecision(commentId: string, on: boolean): Observable<CommandResult> {
return this.gql
.mutate(MarkDecisionDocument, { commentId, on })

View File

@@ -1,9 +1,15 @@
import { inject, Injectable } from '@angular/core';
import { Presence, PresenceGateway } from '@task-stream/web/tracker/domain';
import {
Presence,
PresenceGateway,
Typing,
} from '@task-stream/web/tracker/domain';
import { map, Observable } from 'rxjs';
import {
LeavePresenceDocument,
TouchPresenceDocument,
TypingDocument,
TypistsDocument,
} from '../graphql/generated';
import { GraphqlOperations } from './graphql-operations';
@@ -17,6 +23,18 @@ export class GraphqlPresenceGateway extends PresenceGateway {
.pipe(map((d) => d.touchPresence));
}
typing(issueId: string, threadId: string | null): Observable<void> {
return this.gql
.mutate(TypingDocument, { issueId, threadId })
.pipe(map(() => undefined));
}
typists(issueId: string): Observable<Typing> {
return this.gql
.subscribe(TypistsDocument, { issueId })
.pipe(map((d) => d.typing));
}
leave(issueId: string): Observable<void> {
return this.gql
.mutate(LeavePresenceDocument, { issueId })

View File

@@ -185,6 +185,10 @@ fragment CommentFields on Comment {
decidedBy {
...UserRefFields
}
resolved
resolvedBy {
...UserRefFields
}
createdAt
editedAt
author {

View File

@@ -720,6 +720,7 @@ export type IssueFieldsFragment = {
replyTo: string | null;
deleted: boolean;
decision: boolean;
resolved: boolean;
createdAt: string;
editedAt: string | null;
reactions: Array<{
@@ -727,6 +728,7 @@ export type IssueFieldsFragment = {
users: Array<{ id: string; name: string }>;
}>;
decidedBy: { id: string; name: string } | null;
resolvedBy: { id: string; name: string } | null;
author: { id: string; name: string };
}>;
reporter: { id: string; name: string };
@@ -773,6 +775,7 @@ export type CommentFieldsFragment = {
replyTo: string | null;
deleted: boolean;
decision: boolean;
resolved: boolean;
createdAt: string;
editedAt: string | null;
reactions: Array<{
@@ -780,6 +783,7 @@ export type CommentFieldsFragment = {
users: Array<{ id: string; name: string }>;
}>;
decidedBy: { id: string; name: string } | null;
resolvedBy: { id: string; name: string } | null;
author: { id: string; name: string };
};
@@ -961,6 +965,7 @@ export type IssueQuery = {
replyTo: string | null;
deleted: boolean;
decision: boolean;
resolved: boolean;
createdAt: string;
editedAt: string | null;
reactions: Array<{
@@ -968,6 +973,7 @@ export type IssueQuery = {
users: Array<{ id: string; name: string }>;
}>;
decidedBy: { id: string; name: string } | null;
resolvedBy: { id: string; name: string } | null;
author: { id: string; name: string };
}>;
reporter: { id: string; name: string };
@@ -1181,6 +1187,7 @@ export type IssueCommentsQuery = {
replyTo: string | null;
deleted: boolean;
decision: boolean;
resolved: boolean;
createdAt: string;
editedAt: string | null;
reactions: Array<{
@@ -1188,6 +1195,7 @@ export type IssueCommentsQuery = {
users: Array<{ id: string; name: string }>;
}>;
decidedBy: { id: string; name: string } | null;
resolvedBy: { id: string; name: string } | null;
author: { id: string; name: string };
}>;
};
@@ -1589,6 +1597,16 @@ export type ReactToCommentMutation = {
reactToComment: { id: string; key: string | null; version: number };
};
export type ResolveThreadMutationVariables = Exact<{
commentId: string;
resolved: boolean;
conclusion?: string | null | undefined;
}>;
export type ResolveThreadMutation = {
resolveThread: { id: string; key: string | null; version: number };
};
export type MarkDecisionMutationVariables = Exact<{
commentId: string;
on: boolean;
@@ -1612,6 +1630,7 @@ export type SearchDecisionsQuery = {
replyTo: string | null;
deleted: boolean;
decision: boolean;
resolved: boolean;
createdAt: string;
editedAt: string | null;
reactions: Array<{
@@ -1619,6 +1638,7 @@ export type SearchDecisionsQuery = {
users: Array<{ id: string; name: string }>;
}>;
decidedBy: { id: string; name: string } | null;
resolvedBy: { id: string; name: string } | null;
author: { id: string; name: string };
};
issue: { id: string; key: string; title: string };
@@ -1758,6 +1778,25 @@ export type LeavePresenceMutationVariables = Exact<{
export type LeavePresenceMutation = { leavePresence: boolean };
export type TypingMutationVariables = Exact<{
issueId: string;
threadId?: string | null | undefined;
}>;
export type TypingMutation = { typing: boolean };
export type TypistsSubscriptionVariables = Exact<{
issueId: string;
}>;
export type TypistsSubscription = {
typing: {
threadId: string | null;
at: string;
user: { id: string; name: string };
};
};
export type MyProjectsQueryVariables = Exact<{ [key: string]: never }>;
export type MyProjectsQuery = {
@@ -2916,6 +2955,7 @@ export type CommentChangedSubscription = {
replyTo: string | null;
deleted: boolean;
decision: boolean;
resolved: boolean;
createdAt: string;
editedAt: string | null;
reactions: Array<{
@@ -2923,6 +2963,7 @@ export type CommentChangedSubscription = {
users: Array<{ id: string; name: string }>;
}>;
decidedBy: { id: string; name: string } | null;
resolvedBy: { id: string; name: string } | null;
author: { id: string; name: string };
} | null;
};
@@ -3712,6 +3753,20 @@ export const CommentFieldsFragmentDoc = {
],
},
},
{ kind: 'Field', name: { kind: 'Name', value: 'resolved' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'resolvedBy' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'UserRefFields' },
},
],
},
},
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
{ kind: 'Field', name: { kind: 'Name', value: 'editedAt' } },
{
@@ -4056,6 +4111,20 @@ export const IssueFieldsFragmentDoc = {
],
},
},
{ kind: 'Field', name: { kind: 'Name', value: 'resolved' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'resolvedBy' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'UserRefFields' },
},
],
},
},
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
{ kind: 'Field', name: { kind: 'Name', value: 'editedAt' } },
{
@@ -6304,6 +6373,20 @@ export const IssueDocument = {
],
},
},
{ kind: 'Field', name: { kind: 'Name', value: 'resolved' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'resolvedBy' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'UserRefFields' },
},
],
},
},
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
{ kind: 'Field', name: { kind: 'Name', value: 'editedAt' } },
{
@@ -7684,6 +7767,20 @@ export const IssueCommentsDocument = {
],
},
},
{ kind: 'Field', name: { kind: 'Name', value: 'resolved' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'resolvedBy' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'UserRefFields' },
},
],
},
},
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
{ kind: 'Field', name: { kind: 'Name', value: 'editedAt' } },
{
@@ -10172,6 +10269,114 @@ export const ReactToCommentDocument = {
ReactToCommentMutation,
ReactToCommentMutationVariables
>;
export const ResolveThreadDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'mutation',
name: { kind: 'Name', value: 'ResolveThread' },
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'commentId' },
},
type: {
kind: 'NonNullType',
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
},
},
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'resolved' },
},
type: {
kind: 'NonNullType',
type: {
kind: 'NamedType',
name: { kind: 'Name', value: 'Boolean' },
},
},
},
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'conclusion' },
},
type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } },
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'resolveThread' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'commentId' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'commentId' },
},
},
{
kind: 'Argument',
name: { kind: 'Name', value: 'resolved' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'resolved' },
},
},
{
kind: 'Argument',
name: { kind: 'Name', value: 'conclusion' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'conclusion' },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'CommandResultFields' },
},
],
},
},
],
},
},
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'CommandResultFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'CommandResult' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
{ kind: 'Field', name: { kind: 'Name', value: 'version' } },
],
},
},
],
} as unknown as DocumentNode<
ResolveThreadMutation,
ResolveThreadMutationVariables
>;
export const MarkDecisionDocument = {
kind: 'Document',
definitions: [
@@ -10414,6 +10619,20 @@ export const SearchDecisionsDocument = {
],
},
},
{ kind: 'Field', name: { kind: 'Name', value: 'resolved' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'resolvedBy' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'UserRefFields' },
},
],
},
},
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
{ kind: 'Field', name: { kind: 'Name', value: 'editedAt' } },
{
@@ -11196,6 +11415,141 @@ export const LeavePresenceDocument = {
LeavePresenceMutation,
LeavePresenceMutationVariables
>;
export const TypingDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'mutation',
name: { kind: 'Name', value: 'Typing' },
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'issueId' },
},
type: {
kind: 'NonNullType',
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
},
},
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'threadId' },
},
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'typing' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'issueId' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'issueId' },
},
},
{
kind: 'Argument',
name: { kind: 'Name', value: 'threadId' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'threadId' },
},
},
],
},
],
},
},
],
} as unknown as DocumentNode<TypingMutation, TypingMutationVariables>;
export const TypistsDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'subscription',
name: { kind: 'Name', value: 'Typists' },
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'issueId' },
},
type: {
kind: 'NonNullType',
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'typing' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'issueId' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'issueId' },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'user' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'UserRefFields' },
},
],
},
},
{ kind: 'Field', name: { kind: 'Name', value: 'threadId' } },
{ kind: 'Field', name: { kind: 'Name', value: 'at' } },
],
},
},
],
},
},
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'UserRefFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'UserRef' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
],
},
},
],
} as unknown as DocumentNode<TypistsSubscription, TypistsSubscriptionVariables>;
export const MyProjectsDocument = {
kind: 'Document',
definitions: [
@@ -17206,6 +17560,20 @@ export const CommentChangedDocument = {
],
},
},
{ kind: 'Field', name: { kind: 'Name', value: 'resolved' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'resolvedBy' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'UserRefFields' },
},
],
},
},
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
{ kind: 'Field', name: { kind: 'Name', value: 'editedAt' } },
{

View File

@@ -293,6 +293,20 @@ mutation ReactToComment($commentId: ID!, $emoji: String!, $on: Boolean!) {
}
}
mutation ResolveThread(
$commentId: ID!
$resolved: Boolean!
$conclusion: String
) {
resolveThread(
commentId: $commentId
resolved: $resolved
conclusion: $conclusion
) {
...CommandResultFields
}
}
mutation MarkDecision($commentId: ID!, $on: Boolean!) {
markDecision(commentId: $commentId, on: $on) {
...CommandResultFields

View File

@@ -11,3 +11,17 @@ mutation TouchPresence($issueId: ID!, $editing: Boolean!) {
mutation LeavePresence($issueId: ID!) {
leavePresence(issueId: $issueId)
}
mutation Typing($issueId: ID!, $threadId: ID) {
typing(issueId: $issueId, threadId: $threadId)
}
subscription Typists($issueId: ID!) {
typing(issueId: $issueId) {
user {
...UserRefFields
}
threadId
at
}
}

View File

@@ -641,24 +641,104 @@ a.ts-key {
// A text link, past @pi/ui's global button look: as a 40 px pill it sat on
// the line under the comment.
.ts-reply__open {
justify-self: start;
.ts-thread__actions {
display: flex;
flex-wrap: wrap;
gap: 0.25rem 1rem;
margin-top: 0.375rem;
}
/* Small text buttons, not @pi/ui's sized ones. */
.ts-thread__action {
flex: none;
height: auto !important;
min-width: 0 !important;
margin-top: 0.375rem;
padding: 0.125rem 0 !important;
border: 0;
background: none !important;
color: var(--ts-brand);
font: inherit;
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
&:hover {
&:hover,
&:focus-visible {
text-decoration: underline;
}
}
/* A settled conversation, folded to one line. */
.ts-thread-done {
display: flex;
align-items: center;
gap: 0.75rem;
margin: 0.5rem 0;
padding: 0.625rem 0.875rem;
border: 1px solid var(--ts-line);
border-radius: 0.625rem;
background: var(--ts-sunken);
}
.ts-thread-done__check {
flex: none;
display: inline-grid;
place-items: center;
width: 1.5rem;
height: 1.5rem;
border-radius: 50%;
background: color-mix(in srgb, var(--ts-c-green) 18%, transparent);
color: var(--ts-c-green);
font-weight: 700;
}
.ts-thread-done__text {
display: grid;
flex: 1;
min-width: 0;
font-size: 0.875rem;
> span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.ts-thread-done__topic {
font-weight: 600;
}
.ts-open-threads {
margin-left: auto;
padding: 0.0625rem 0.5rem;
border-radius: 999px;
background: color-mix(in srgb, var(--ts-c-amber) 18%, transparent);
color: var(--ts-ink);
font-size: 0.6875rem;
letter-spacing: 0.04em;
}
.ts-typing {
margin: 0;
color: var(--ts-muted);
font-size: 0.8125rem;
font-style: italic;
}
.ts-resolve__label {
display: grid;
gap: 0.375rem;
color: var(--ts-muted);
font-size: 0.8125rem;
}
.ts-resolve__text {
font: inherit;
color: var(--ts-ink);
resize: vertical;
}
.ts-reply__actions {
display: flex;
gap: 0.75rem;

View File

@@ -53,6 +53,7 @@ import {
isTyping,
POINT_SCALE,
ReleaseStatus,
StatusCategory,
} from '@task-stream/web/tracker/domain';
import {
clock,
@@ -654,6 +655,16 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
<h2 class="ts-section">
Comentarios
<span class="ts-mono">{{ commentCount() }}</span>
@if (openThreads()) {
<span class="ts-open-threads"
>{{ openThreads() }}
{{
openThreads() === 1
? 'conversación abierta'
: 'conversaciones abiertas'
}}</span
>
}
</h2>
<ng-template #commentView let-comment>
<div
@@ -761,68 +772,188 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
</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
@if (
thread.comment.resolved && !expanded().has(thread.comment.id)
) {
<!-- Settled: one line, the conversation a click away. -->
<div class="ts-thread-done">
<span class="ts-thread-done__check" aria-hidden="true"
>✓</span
>
<ts-rich-editor
[placeholder]="
'Responde a ' + thread.comment.author.name + '…'
"
[(value)]="replyBody"
[members]="mentionable()"
[me]="facade.me()"
[upload]="pasteImage"
[loader]="loadImage"
(submitted)="reply(thread.comment)"
/>
<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.canReport() && !thread.comment.deleted) {
<div class="ts-thread-done__text">
<span class="ts-thread-done__topic">{{
thread.comment.deleted
? 'Conversación'
: excerpt(thread.comment.body)
}}</span>
<span class="ts-muted"
>Resuelta{{
thread.comment.resolvedBy
? ' por ' + thread.comment.resolvedBy.name
: ''
}}{{
conclusionOf(thread)
? ': ' + excerpt(conclusionOf(thread) ?? '')
: ''
}}</span
>
</div>
<button
type="button"
class="ts-plain-link ts-reply__open"
(click)="startReply(thread.comment)"
class="ts-thread__action"
(click)="toggleThread(thread.comment.id)"
>
Responder{{
thread.replies.length
? ' (' + thread.replies.length + ')'
: ''
}}
Ver conversación ({{ thread.replies.length + 1 }})
</button>
}
</div>
</div>
} @else {
<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 (typistsIn(thread.comment.id); as typing) {
<p class="ts-typing" aria-live="polite">{{ typing }}</p>
}
@if (resolving() === thread.comment.id) {
<form
class="ts-form ts-reply"
(submit)="
$event.preventDefault(); resolve(thread.comment)
"
novalidate
>
<label class="ts-resolve__label">
Conclusión (opcional): quedará como decisión, arriba del
ticket
<textarea
class="ts-input ts-resolve__text"
rows="2"
maxlength="20000"
placeholder="Vamos con…"
[value]="conclusion()"
(input)="conclusion.set($any($event.target).value)"
></textarea>
</label>
<div class="ts-reply__actions">
<pi-action-button
color="primary"
text="Resolver"
(execute)="resolve(thread.comment)"
/>
<button
type="button"
class="ts-plain-link"
(click)="resolving.set(null)"
>
Cancelar
</button>
</div>
</form>
} @else if (replyingTo() === thread.comment.id) {
<form
class="ts-form ts-reply"
(submit)="$event.preventDefault(); reply(thread.comment)"
novalidate
>
<ts-rich-editor
[placeholder]="
'Responde a ' + thread.comment.author.name + '…'
"
[(value)]="replyBody"
[members]="mentionable()"
[me]="facade.me()"
[upload]="pasteImage"
[loader]="loadImage"
[enterSends]="true"
(submitted)="reply(thread.comment)"
/>
<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 {
<div class="ts-thread__actions">
@if (project.canReport() && !thread.comment.deleted) {
<button
type="button"
class="ts-thread__action"
(click)="startReply(thread.comment)"
>
Responder{{
thread.replies.length
? ' (' + thread.replies.length + ')'
: ''
}}
</button>
}
@if (project.canContribute()) {
@if (thread.comment.resolved) {
<button
type="button"
class="ts-thread__action"
(click)="
facade.resolveThread(thread.comment.id, false)
"
>
Reabrir
</button>
<button
type="button"
class="ts-thread__action"
(click)="toggleThread(thread.comment.id)"
>
Plegar
</button>
} @else if (
thread.replies.length && !thread.comment.deleted
) {
<button
type="button"
class="ts-thread__action"
(click)="startResolve(thread.comment)"
>
✓ Resolver
</button>
}
} @else if (thread.comment.resolved) {
<button
type="button"
class="ts-thread__action"
(click)="toggleThread(thread.comment.id)"
>
Plegar
</button>
}
</div>
}
</div>
}
}
@if (typistsIn(null); as typing) {
<p class="ts-typing" aria-live="polite">{{ typing }}</p>
}
@if (project.canReport()) {
<form
@@ -1247,6 +1378,11 @@ export class IssuePage {
/** The comment being answered, and the answer being written. */
protected readonly replyingTo = signal<string | null>(null);
protected readonly replyBody = signal('');
/** The conversation being settled, and its conclusion. */
protected readonly resolving = signal<string | null>(null);
protected readonly conclusion = signal('');
/** Settled conversations unfolded to read. */
protected readonly expanded = signal<ReadonlySet<string>>(new Set());
/** Top-level comments, each with its replies (oldest first). */
protected readonly threads = computed(() => {
const comments = this.facade.comments();
@@ -1414,6 +1550,14 @@ export class IssuePage {
if (id && this.facade.comments().some((c) => c.id === id))
untracked(() => setTimeout(() => this.goToComment(id)));
});
// Writing an answer or a comment: the others see it as I type.
effect(() => {
if (this.replyBody().trim())
untracked(() => this.presence.typed(this.replyingTo()));
});
effect(() => {
if (this.commentBody().trim()) untracked(() => this.presence.typed(null));
});
effect(() => {
const editing = this.editingInDrawer() || !!this.commentBody().trim();
untracked(() => this.presence.setEditing(editing));
@@ -1473,6 +1617,17 @@ export class IssuePage {
}
protected move(to: string): void {
const open = this.openThreads();
if (
open &&
this.project.workflow().status(to)?.category === StatusCategory.DONE &&
!confirm(
open === 1
? 'Queda una conversación sin resolver. ¿Terminar el ticket igualmente?'
: `Quedan ${open} conversaciones sin resolver. ¿Terminar el ticket igualmente?`,
)
)
return;
if (this.project.workflow().requiresReason(to)) {
this.pendingStatus = to;
this.reason().reset({ text: '' });
@@ -1565,6 +1720,68 @@ export class IssuePage {
this.replyingTo.set(comment.id);
}
protected startResolve(comment: Comment): void {
this.conclusion.set('');
this.replyingTo.set(null);
this.resolving.set(comment.id);
}
protected resolve(comment: Comment): void {
this.facade.resolveThread(comment.id, true, this.conclusion());
this.resolving.set(null);
this.conclusion.set('');
// It folds once settled.
this.expanded.update((ids) => {
const next = new Set(ids);
next.delete(comment.id);
return next;
});
}
protected toggleThread(id: string): void {
this.expanded.update((ids) => {
const next = new Set(ids);
if (!next.delete(id)) next.add(id);
return next;
});
}
/** Conversations with answers still to settle. */
protected readonly openThreads = computed(
() =>
this.threads().filter(
(t) => t.replies.length && !t.comment.resolved && !t.comment.deleted,
).length,
);
/** What a settled conversation concluded: its last reply marked as a decision. */
protected conclusionOf(thread: { replies: Comment[] }): string | null {
const decided = thread.replies.filter((r) => r.decision && !r.deleted);
return decided[decided.length - 1]?.body ?? null;
}
/** The first words of a comment, on one line. */
protected excerpt(body: string): string {
const text = body
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1')
.replace(/[*_`~#>]+/g, '')
.replace(/\s+/g, ' ')
.trim();
return text.length > 80 ? `${text.slice(0, 79)}…` : text;
}
/** «Ana está escribiendo…» in a conversation (null: a new comment). */
protected typistsIn(threadId: string | null): string | null {
const names = this.presence
.typists()
.filter((t) => t.threadId === threadId)
.map((t) => t.user.name);
if (!names.length) return null;
return names.length === 1
? `${names[0]} está escribiendo…`
: `${names.slice(0, -1).join(', ')} y ${names[names.length - 1]} están escribiendo…`;
}
protected reply(comment: Comment): void {
const body = this.replyBody().trim();
if (!body) return;

View File

@@ -423,7 +423,9 @@ export class RichEditorComponent implements FormValueControl<string> {
/** Turns pasting screenshots on. */
readonly upload = input<ImageUploader | null>(null);
readonly loader = input<AttachmentLoader | null>(null);
/** Ctrl/Cmd+Enter. */
/** Plain Enter sends too (Shift+Enter breaks the line), like a chat. */
readonly enterSends = input(false);
/** Ctrl/Cmd+Enter (and Enter with `enterSends`). */
readonly submitted = output<void>();
readonly touch = output<void>();
@@ -440,6 +442,7 @@ export class RichEditorComponent implements FormValueControl<string> {
});
protected readonly hint = computed(() =>
[
this.enterSends() ? 'Enter envía, Shift+Enter cambia de línea' : '',
this.members().length ? 'Escribe @ para mencionar a alguien' : '',
this.upload() ? 'pega capturas con Ctrl+V' : '',
]
@@ -539,6 +542,7 @@ export class RichEditorComponent implements FormValueControl<string> {
placeholder: () => this.placeholder(),
loader: this.loader(),
onSubmit: () => this.submitted.emit(),
enterSends: () => this.enterSends(),
suggestion: {
items: ({ query }) =>
this.members()

View File

@@ -106,6 +106,11 @@ export interface RichTextOptions {
suggestion?: Omit<SuggestionOptions<Mentionable>, 'editor'> | null;
/** Ctrl/Cmd+Enter. */
onSubmit?: () => void;
/**
* Plain Enter submits too (Shift+Enter breaks the line), like a chat;
* not inside a list or code, where Enter means a new item or line.
*/
enterSends?: () => boolean;
}
/**
@@ -152,6 +157,17 @@ export function richTextExtensions(
options.onSubmit?.();
return !!options.onSubmit;
},
Enter: ({ editor }) => {
if (!options.onSubmit || !options.enterSends?.()) return false;
if (
editor.isActive('codeBlock') ||
editor.isActive('listItem') ||
editor.isActive('taskItem')
)
return false;
options.onSubmit();
return true;
},
}),
}),
Markdown.configure({ markedOptions: { gfm: true, breaks: true } }),