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([