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:
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user