feat: comments marked as decisions, on top of the ticket and searchable

Whoever can change the ticket flags a comment as a decision (new event
CommentDecisionMarkedEvent on the comment). The ticket page lists its
decisions above the description, with a link to the comment in the
conversation (`?comment=<id>` scrolls to it and points at it), and the
search palette finds them by their text across my projects.

API: markDecision / PUT-DELETE /api/comments/{id}/decision; Issue.decisions
and GET /api/issues/{issue}/decisions; decisions(projectId?, search) and
GET /api/projects/{project}/decisions, GET /api/me/decisions. MCP:
mark_decision, search_decisions, and get_issue includes them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-17 11:32:07 +02:00
parent 8f83fdefb6
commit 70e4fbd399
45 changed files with 2515 additions and 84 deletions

View File

@@ -331,6 +331,109 @@ a.ts-key {
}
}
/* A decision: marked on its comment, and listed above the ticket. */
.ts-comment.is-decision {
margin-inline: -0.75rem;
padding-inline: 0.75rem;
border-left: 3px solid var(--ts-c-green);
background: color-mix(in srgb, var(--ts-c-green) 6%, transparent);
}
.ts-comment.is-pointed {
animation: ts-pointed 2.4s ease-out;
}
@keyframes ts-pointed {
0%,
40% {
box-shadow: 0 0 0 3px color-mix(in srgb, var(--ts-brand) 45%, transparent);
}
100% {
box-shadow: 0 0 0 3px transparent;
}
}
.ts-comment__decision {
padding: 0.0625rem 0.5rem;
border-radius: 999px;
background: color-mix(in srgb, var(--ts-c-green) 16%, transparent);
color: var(--ts-c-green);
font-size: 0.6875rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
/* A plain icon button, not @pi/ui's sized one. */
.ts-comment__flag {
display: inline-grid;
place-items: center;
width: 2rem !important;
min-width: 0 !important;
height: 2rem !important;
padding: 0 !important;
border: 0;
border-radius: 0.5rem;
background: transparent;
color: var(--ts-muted);
cursor: pointer;
&:hover,
&:focus-visible {
background: var(--ts-sunken);
color: var(--ts-ink);
}
&.is-on {
color: var(--ts-c-green);
}
}
.ts-decisions {
ol {
display: grid;
gap: 0.875rem;
margin: 0;
padding: 0;
list-style: none;
}
li {
padding: 0.75rem 1rem;
border-left: 3px solid var(--ts-c-green);
border-radius: 0 0.5rem 0.5rem 0;
background: color-mix(in srgb, var(--ts-c-green) 7%, var(--ts-surface));
}
.ts-section pi-icon {
color: var(--ts-c-green);
}
}
.ts-decisions__meta {
margin: 0.375rem 0 0;
color: var(--ts-muted);
font-size: 0.8125rem;
}
/* Looks like a link: not @pi/ui's sized button. */
.ts-decisions__go {
display: inline !important;
min-width: 0 !important;
height: auto !important;
padding: 0 !important;
border: 0;
background: none !important;
color: var(--ts-brand);
font: inherit;
cursor: pointer;
&:hover,
&:focus-visible {
text-decoration: underline;
}
}
.ts-comment__head {
display: flex;
align-items: center;

View File

@@ -259,6 +259,47 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
</div>
}
@if (issue.decisions.length) {
<article
class="ts-block ts-decisions"
aria-labelledby="decisions-title"
>
<h2 class="ts-section" id="decisions-title">
<pi-icon icon="flag" size="small" />
Decisiones
<span class="ts-mono">{{ issue.decisions.length }}</span>
</h2>
<ol>
@for (decision of issue.decisions; track decision.id) {
<li>
<ts-markdown
[text]="decision.body"
[me]="facade.me()"
[loader]="loadImage"
/>
<p class="ts-decisions__meta">
{{ decision.author.name }} ·
{{ ago(decision.createdAt) }}
@if (decision.decidedBy; as by) {
@if (by.id !== decision.author.id) {
· marcada por {{ by.name }}
}
}
·
<button
type="button"
class="ts-decisions__go"
(click)="goToComment(decision.id)"
>
Ver en la conversación
</button>
</p>
</li>
}
</ol>
</article>
}
<article class="ts-block">
<h2 class="ts-section">Descripción</h2>
@if (issue.description) {
@@ -615,7 +656,11 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
<span class="ts-mono">{{ commentCount() }}</span>
</h2>
<ng-template #commentView let-comment>
<div class="ts-comment">
<div
class="ts-comment"
[id]="'comment-' + comment.id"
[class.is-decision]="comment.decision && !comment.deleted"
>
<span class="ts-comment__avatar" aria-hidden="true">{{
initialsOf(comment.author.name)
}}</span>
@@ -628,7 +673,34 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
>{{ ago(comment.createdAt)
}}{{ comment.editedAt ? ' · editado' : '' }}</span
>
@if (comment.decision && !comment.deleted) {
<span class="ts-comment__decision">Decisión</span>
}
<span class="ts-spacer"></span>
@if (!comment.deleted && project.canContribute()) {
<!-- Native: pi-icon-button leaves its button unnamed. -->
<button
type="button"
class="ts-comment__flag"
[class.is-on]="comment.decision"
[attr.aria-pressed]="comment.decision"
[attr.aria-label]="
comment.decision
? 'Quitar de las decisiones'
: 'Marcar como decisión'
"
[attr.title]="
comment.decision
? 'Quitar de las decisiones'
: 'Marcar como decisión: sale arriba del ticket'
"
(click)="
facade.markDecision(comment.id, !comment.decision)
"
>
<pi-icon icon="flag" size="small" />
</button>
}
@if (
!comment.deleted && comment.author.id === facade.me()
) {
@@ -1028,6 +1100,8 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
export class IssuePage {
/** Route param (`withComponentInputBinding`). */
readonly issueKey = input.required<string>();
/** `?comment=` (query param): the comment to bring into view. */
readonly comment = input<string>();
protected readonly facade = inject(IssueFacade);
protected readonly project = inject(ProjectFacade);
@@ -1334,12 +1408,29 @@ export class IssuePage {
});
});
});
effect(() => {
const id = this.comment();
// Once its comment has arrived (they load after the ticket).
if (id && this.facade.comments().some((c) => c.id === id))
untracked(() => setTimeout(() => this.goToComment(id)));
});
effect(() => {
const editing = this.editingInDrawer() || !!this.commentBody().trim();
untracked(() => this.presence.setEditing(editing));
});
}
/** Scrolls to a comment of the conversation and points at it. */
protected goToComment(id: string): void {
const element = document.getElementById(`comment-${id}`);
if (!element) return;
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
element.classList.remove('is-pointed');
// Restart the animation when it is pointed at again.
void element.offsetWidth;
element.classList.add('is-pointed');
}
/** While a form is open in the drawer, I am editing. */
private trackEditing(ref: {
afterClosed(): { subscribe(f: () => void): unknown };

View File

@@ -14,6 +14,7 @@ import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
import { Router } from '@angular/router';
import { ProjectsFacade } from '@task-stream/web/tracker/application';
import {
Decision,
GO_TO,
isTyping,
IssuesGateway,
@@ -23,6 +24,7 @@ import {
} from '@task-stream/web/tracker/domain';
import {
catchError,
combineLatest,
debounceTime,
distinctUntilChanged,
map,
@@ -39,9 +41,28 @@ const issueResult = (i: IssueSummary): Result => ({
url: `/p/${i.key.slice(0, i.key.lastIndexOf('-'))}/issues/${i.key}`,
});
/** A decision as a result: opens its ticket at the comment. */
const decisionResult = ({ comment, issue }: Decision): Result => ({
id: `d:${comment.id}`,
kind: 'decision',
label: `${issue.key} · ${excerpt(comment.body)}`,
detail: comment.author.name,
url: `/p/${issue.key.slice(0, issue.key.lastIndexOf('-'))}/issues/${issue.key}?comment=${comment.id}`,
});
/** The first words of a comment, without Markdown marks. */
function excerpt(body: string): string {
const text = body
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1')
.replace(/[*_`~#>]+/g, '')
.replace(/\s+/g, ' ')
.trim();
return text.length > 90 ? `${text.slice(0, 89)}…` : text;
}
interface Result {
id: string;
kind: 'project' | 'issue';
kind: 'project' | 'issue' | 'decision';
label: string;
detail: string;
url: string;
@@ -83,7 +104,7 @@ interface Result {
#search
class="ts-palette__input"
type="search"
placeholder="Busca un ticket (WEB-12, título…) o un proyecto"
placeholder="Busca un ticket (WEB-12, título…), una decisión o un proyecto"
role="combobox"
aria-controls="ts-palette-results"
[attr.aria-expanded]="results().length > 0"
@@ -113,9 +134,11 @@ interface Result {
<span class="ts-palette__kind">{{
result.kind === 'project'
? 'Proyecto'
: text().trim().length < 2
? 'Fijado'
: 'Ticket'
: result.kind === 'decision'
? 'Decisión'
: text().trim().length < 2
? 'Fijado'
: 'Ticket'
}}</span>
<span class="ts-palette__label">{{ result.label }}</span>
<span class="ts-palette__detail">{{ result.detail }}</span>
@@ -268,6 +291,7 @@ export class CommandPalette {
private goTimer: ReturnType<typeof setTimeout> | null = null;
private readonly foundIssues = signal<IssueSummary[]>([]);
private readonly foundDecisions = signal<Decision[]>([]);
/** Mine, shown before typing; read again each time it opens. */
protected readonly pins = signal<IssueSummary[]>([]);
protected readonly results = computed<Result[]>(() => {
@@ -288,7 +312,11 @@ export class CommandPalette {
detail: p.key,
url: `/p/${p.key}/board`,
}));
return [...projects, ...this.foundIssues().map(issueResult)];
return [
...projects,
...this.foundIssues().map(issueResult),
...this.foundDecisions().map(decisionResult),
];
});
constructor() {
@@ -299,15 +327,25 @@ export class CommandPalette {
distinctUntilChanged(),
switchMap((search) =>
search.length < 2
? of([])
: this.issues.mine({ assignedToMe: false, search, limit: 8 }).pipe(
map((page) => page.items),
catchError(() => of([])),
),
? of<[IssueSummary[], Decision[]]>([[], []])
: combineLatest([
this.issues
.mine({ assignedToMe: false, search, limit: 8 })
.pipe(
map((page) => page.items),
catchError(() => of([])),
),
this.issues
.searchDecisions(search, 4)
.pipe(catchError(() => of([]))),
]),
),
takeUntilDestroyed(this.destroyRef),
)
.subscribe((found) => this.foundIssues.set(found));
.subscribe((found) => {
this.foundIssues.set(found[0]);
this.foundDecisions.set(found[1]);
});
effect(() => {
if (this.mode() === 'search') this.searchInput()?.nativeElement.focus();
});