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:
@@ -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' } },
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user