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

@@ -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
}
}