import { LitElement, html, css, unsafeCSS } from 'lit';
import { customElement, state, property } from 'lit/decorators.js';
import { when } from 'lit/directives/when.js';
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
import '@shoelace-style/shoelace/dist/components/card/card.js';
import '@shoelace-style/shoelace/dist/components/button/button.js';
import '@shoelace-style/shoelace/dist/components/icon/icon.js';
import '@shoelace-style/shoelace/dist/components/alert/alert.js';
import '@shoelace-style/shoelace/dist/components/badge/badge.js';
import '@shoelace-style/shoelace/dist/components/tag/tag.js';
import '@shoelace-style/shoelace/dist/components/button-group/button-group.js';
import '@shoelace-style/shoelace/dist/components/icon-button/icon-button.js';
import '@shoelace-style/shoelace/dist/components/spinner/spinner.js';
import '@shoelace-style/shoelace/dist/components/tooltip/tooltip.js';
import '../../components/project-filter-modal.ts';
import '../../components/duplicate-stats-chart.ts';
import '../../components/resolve-issue-modal.ts';
import '../../components/issue-detail-view.ts';
import '../../components/pagination-controls.ts';
import '../../components/view-header.ts';
import {
listProjects,
listIssueDuplicates,
checkAIVerdict,
dismissDuplicatePair,
listOrganizations,
} from '../../api';
import type {
Project,
DuplicatePair,
DuplicatesResponse,
Organization,
Issue,
} from '../../types';
import {
DEFAULT_SIMILARITY_THRESHOLD,
DEFAULT_SIMILARITY_THRESHOLD_CHARTS,
} from '../../config';
import {
AIModelVerdict,
renderVerdict,
getStatusVariant,
} from '../../utils/verdict';
import consoleStyles from '../../styles/console-styles.css?inline';
@customElement('issues-view')
export class IssuesView extends LitElement {
private readonly INFO_ALERT_DISMISSED_KEY =
'preloop-issues-info-alert-dismissed';
@state()
private _isInfoAlertOpen = false;
@state()
private _duplicates: DuplicatePair[] = [];
@state()
private _aiVerdicts: Record<string, AIModelVerdict> = {};
@state()
private _loadingVerdicts: Record<string, boolean> = {};
@state()
private _loading = false;
@state()
private _error: string | null = null;
@state()
private _currentPage = 1;
@state()
private _pageSize = 10;
@state()
private _hasMorePages = true;
@state()
private _expandedRowKey: string | null = null;
@state()
private _isFilterModalOpen = false;
@state()
private _resolutionSummary: string | null = null;
@state()
private _isResolveModalOpen = false;
@state()
private _selectedPair: DuplicatePair | null = null;
@state()
private _selectedProjectIds: string[] = [];
@state()
private _selectedStatus: 'opened' | 'closed' | 'all' = 'opened';
@state()
private _selectedResolutionStatus: 'resolved' | 'unresolved' | 'all' = 'all';
private _similarityThreshold = DEFAULT_SIMILARITY_THRESHOLD;
private _similarityThresholdCharts = DEFAULT_SIMILARITY_THRESHOLD_CHARTS;
@state()
private _allProjects: Project[] = [];
@state()
private _hasProjects = true;
@state()
private _organizations: Organization[] = [];
@state()
private _initialLoadComplete = false;
static styles = [
unsafeCSS(consoleStyles),
css`
.table-card {
width: 100%;
--padding: 0;
border-spacing: 0;
}
.styled-table th,
.styled-table td {
padding: var(--sl-spacing-medium);
border-bottom: 1px solid var(--sl-color-neutral-200);
}
.styled-table .issue-id {
font-weight: var(--sl-font-weight-semibold);
}
.issue-key {
color: var(--sl-color-neutral-600);
}
.faint-row {
opacity: 0.5;
transition: opacity 0.3s ease-in-out;
}
.clickable-row {
cursor: pointer;
}
.row-expanded {
background-color: var(--sl-color-primary-50);
}
.loading-overlay {
color: white;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: var(--sl-spacing-medium);
z-index: 10000;
}
sl-icon {
font-size: 1rem;
}
.placeholder-content {
text-align: center;
}
`,
];
async connectedCallback() {
super.connectedCallback();
const isDismissed = localStorage.getItem(this.INFO_ALERT_DISMISSED_KEY);
this._isInfoAlertOpen = isDismissed !== 'true';
// Fetch projects first so we can map short IDs from the URL to full IDs.
await this.fetchProjects();
this.parseUrlAndUpdateState();
this.fetchDuplicates();
this.fetchOrganizations();
this._initialLoadComplete = true;
window.addEventListener('popstate', this.handlePopState);
}
disconnectedCallback() {
super.disconnectedCallback();
window.removeEventListener('popstate', this.handlePopState);
// Check if we are still on the issues path before cleaning up.
// This prevents a race condition where the URL of the *next* page is cleaned.
if (window.location.pathname.includes('/issues')) {
window.history.replaceState({}, '', window.location.pathname);
}
}
private handlePopState = () => {
this.parseUrlAndUpdateState();
this.fetchDuplicates();
};
private parseUrlAndUpdateState() {
const params = new URLSearchParams(window.location.search);
this._currentPage = parseInt(params.get('page') || '1', 10);
this._selectedStatus = (params.get('status') || 'opened') as
| 'opened'
| 'closed'
| 'all';
this._selectedResolutionStatus = (params.get('resolution') || 'all') as
| 'resolved'
| 'unresolved'
| 'all';
const shortProjectIds = params.get('projects');
if (shortProjectIds && this._allProjects.length > 0) {
const shortIdSet = new Set(shortProjectIds.split(','));
this._selectedProjectIds = this._allProjects
.filter((p) => shortIdSet.has(p.id.split('-')[0]))
.map((p) => p.id);
} else {
this._selectedProjectIds = [];
}
this._expandedRowKey = params.get('selectedPair') || null;
}
private _updateUrl() {
// Only update the URL if we are on the issues page.
if (!window.location.pathname.includes('/issues')) {
return;
}
const params = new URLSearchParams();
params.set('page', this._currentPage.toString());
params.set('status', this._selectedStatus);
if (this._selectedResolutionStatus !== 'all') {
params.set('resolution', this._selectedResolutionStatus);
}
if (this._selectedProjectIds.length > 0) {
const shortProjectIds = this._selectedProjectIds.map(
(id) => id.split('-')[0]
);
params.set('projects', shortProjectIds.join(','));
}
if (this._expandedRowKey) {
params.set('selectedPair', this._expandedRowKey);
} else {
params.delete('selectedPair');
}
const newUrl = `${window.location.pathname}?${params.toString()}`;
window.history.pushState({}, '', newUrl);
}
async fetchInitialData() {
this.fetchDuplicates();
this.fetchProjects();
this.fetchOrganizations();
}
async fetchProjects() {
try {
this._allProjects = await listProjects();
this._hasProjects = this._allProjects.length > 0;
} catch (error) {
console.error('Failed to fetch project list:', error);
this._hasProjects = false; // Set to false on error
}
}
async fetchOrganizations() {
try {
this._organizations = await listOrganizations();
} catch (error) {
console.error('Failed to fetch organization list:', error);
}
}
async fetchDuplicates() {
this._loading = true;
this._error = null;
const skip = (this._currentPage - 1) * this._pageSize;
try {
const data: DuplicatesResponse = await listIssueDuplicates({
limit: this._pageSize,
skip: skip,
project_ids: this._selectedProjectIds,
status: this._selectedStatus,
resolution: this._selectedResolutionStatus,
similarity_threshold: this._similarityThreshold,
});
this._duplicates = data.duplicates;
this._hasMorePages = data.duplicates.length === this._pageSize;
this._updateUrl(); // Update URL after fetching
this.fetchAIModelVerdicts(); // Fetch verdicts after getting duplicates
} catch (error) {
this._error =
error instanceof Error ? error.message : 'An unknown error occurred.';
console.error('Failed to fetch duplicate issues:', error);
} finally {
this._loading = false;
}
}
async fetchAIModelVerdicts() {
const newVerdicts = { ...this._aiVerdicts };
const newLoadingVerdicts = { ...this._loadingVerdicts };
const promises = this._duplicates.map((pair) => {
const pairKey = `${pair.issue1.id}-${pair.issue2.id}`;
if (newVerdicts[pairKey]) {
return Promise.resolve();
}
newLoadingVerdicts[pairKey] = true;
return checkAIVerdict(pair.issue1.id, pair.issue2.id)
.then((verdict) => {
newVerdicts[pairKey] = verdict;
})
.catch((error) => {
console.error(`Failed to fetch AI verdict for ${pairKey}:`, error);
// Store a failed state if needed, or just remove loading indicator
})
.finally(() => {
newLoadingVerdicts[pairKey] = false;
});
});
this._loadingVerdicts = newLoadingVerdicts;
await Promise.all(promises);
this._aiVerdicts = newVerdicts;
}
private _toggleRow(pairKey: string) {
if (this._expandedRowKey === pairKey) {
this._expandedRowKey = null;
} else {
this._expandedRowKey = pairKey;
}
this._updateUrl();
}
private _openResolveModal(pair: DuplicatePair) {
this._selectedPair = pair;
this._isResolveModalOpen = true;
}
private async _handleDismiss(pair: DuplicatePair) {
// Optimistically remove the pair from the list for a responsive UI
const pairKey = `${pair.issue1.id}-${pair.issue2.id}`;
const originalDuplicates = [...this._duplicates];
this._duplicates = this._duplicates.filter(
(p) => `${p.issue1.id}-${p.issue2.id}` !== pairKey
);
try {
await dismissDuplicatePair(pair.issue1.id, pair.issue2.id);
} catch (error) {
console.error('Failed to dismiss pair:', error);
// If the API call fails, revert the UI change
this._duplicates = originalDuplicates;
// Optionally, show an error toast to the user here
}
}
private _handleModalClose() {
this._isResolveModalOpen = false;
this._selectedPair = null;
}
private async handleResolution(e: CustomEvent) {
if (e.detail.summary) {
this._resolutionSummary = e.detail.summary;
}
this.fetchDuplicates();
}
private renderDetailRow(pair: DuplicatePair) {
const pairKey = `${pair.issue1.id}-${pair.issue2.id}`;
const aiVerdict = this._aiVerdicts[pairKey];
const loadingVerdict = this._loadingVerdicts[pairKey];
return html`
<issue-detail-view
.pair=${pair}
.aiVerdict=${aiVerdict}
.loadingVerdict=${loadingVerdict}
@resolve=${() => this._openResolveModal(pair)}
@dismiss=${() => this._handleDismiss(pair)}
></issue-detail-view>
`;
}
private _openFilterModal() {
this._isFilterModalOpen = true;
}
private _removeProjectFilter(projectIdToRemove: string) {
this._selectedProjectIds = this._selectedProjectIds.filter(
(id) => id !== projectIdToRemove
);
this.fetchDuplicates();
}
private _clearAllFilters() {
this._selectedProjectIds = [];
this.fetchDuplicates();
}
private _handleProjectSelectedFromChart(event: CustomEvent) {
const { projectId } = event.detail;
if (projectId && !this._selectedProjectIds.includes(projectId)) {
this._selectedProjectIds = [...this._selectedProjectIds, projectId];
this.fetchDuplicates();
}
}
private _renderActiveFilters() {
if (
this._selectedProjectIds.length === 0 &&
this._selectedStatus === 'opened' &&
this._selectedResolutionStatus === 'all'
) {
return html``;
}
const selectedProjects = this._selectedProjectIds
.map((id) => this._allProjects.find((p) => p.id.toString() === id))
.filter(Boolean) as Project[];
return html`
<div class="active-filters">
<span>Filtered by:</span>
${selectedProjects.map(
(project) => html`
<sl-tag
size="medium"
removable
@sl-remove=${() =>
this._removeProjectFilter(project.id.toString())}
>
${project.name}
</sl-tag>
`
)}
${this._selectedStatus !== 'opened'
? html`
<sl-tag
size="medium"
removable
@sl-remove=${() => this._clearStatusFilter()}
>
${this._selectedStatus === 'closed' ? 'Closed' : 'All'}
</sl-tag>
`
: ''}
${this._selectedResolutionStatus !== 'all'
? html`
<sl-tag
size="medium"
removable
@sl-remove=${() => this._clearResolutionFilter()}
>
${this._selectedResolutionStatus === 'resolved'
? 'Resolved'
: 'Unresolved'}
</sl-tag>
`
: ''}
<sl-button size="small" pill @click=${this._clearAllFilters}
>Clear all</sl-button
>
</div>
`;
}
private _clearStatusFilter() {
this._selectedStatus = 'opened';
this.fetchDuplicates();
}
private _clearResolutionFilter() {
this._selectedResolutionStatus = 'all';
this.fetchDuplicates();
}
private handleInfoAlertHide() {
localStorage.setItem(this.INFO_ALERT_DISMISSED_KEY, 'true');
this._isInfoAlertOpen = false;
}
private _goToPreviousPage() {
if (this._currentPage > 1) {
this._currentPage--;
this.fetchDuplicates();
}
}
private _goToNextPage() {
this._currentPage++;
this.fetchDuplicates();
}
render() {
return html`
<view-header headerText="Issue Similarity" width="wide">
<div slot="main-column">
<sl-button @click=${this._openFilterModal}>
<sl-icon slot="prefix" name="filter"></sl-icon>
Filter
</sl-button>
</div>
</view-header>
<div class="column-layout wide">
<div class="main-column">
<div class="container">
<sl-alert
variant="primary"
?open=${this._isInfoAlertOpen}
closable
@sl-hide=${this.handleInfoAlertHide}
>
<sl-icon slot="icon" name="info-circle"></sl-icon>
<strong>Find similar issues and resolve duplicates</strong><br />
Identify similar and potential duplicate issues across your
projects. Review each suggested pair, check the similarity score,
and use the AI review to resolve or dismiss the suggestion.
</sl-alert>
${when(
this._initialLoadComplete && this._hasProjects,
() => html`
<sl-card class="embedding-card">
<div slot="header" class="chart-header">
Similar Issues per Project
<sl-tooltip
content="Showing issues with a similarity score of ${this
._similarityThresholdCharts * 100}% or higher."
>
<sl-icon name="question-circle"></sl-icon>
</sl-tooltip>
</div>
<duplicate-stats-chart
.hasProjects=${this._hasProjects}
.projectIds=${this._selectedProjectIds}
.selectedStatus=${this._selectedStatus}
.selectedResolution=${this._selectedResolutionStatus}
.similarityThreshold=${this._similarityThresholdCharts}
?interactive=${true}
@project-selected=${this._handleProjectSelectedFromChart}
></duplicate-stats-chart>
</sl-card>
`
)}
${when(
this._resolutionSummary,
() => html`
<sl-alert
variant="success"
open
closable
@sl-after-hide=${() => (this._resolutionSummary = null)}
>
<sl-icon slot="icon" name="check-circle"></sl-icon>
${this._resolutionSummary}
</sl-alert>
`
)}
${this._renderActiveFilters()}
${when(
this._loading,
() =>
html`<div class="loading-overlay">
<sl-spinner></sl-spinner>
<span>Loading issues...</span>
</div>`
)}
${when(
this._error,
() => html`<div class="error">Error: ${this._error}</div>`
)}
${when(!this._loading && !this._error, () =>
this._duplicates.length > 0
? html`
<sl-card class="table-card">
<table class="styled-table">
<thead>
<tr>
<th>Issue 1</th>
<th>Issue 2</th>
<th class="text-right">Similarity</th>
<th class="text-right">AI Review</th>
<th class="text-right">Actions</th>
</tr>
</thead>
<tbody>
${this._duplicates.map((pair) => {
const pairKey = `${pair.issue1.id}-${pair.issue2.id}`;
const verdict = this._aiVerdicts[pairKey];
const isFaint = this._loadingVerdicts[pairKey];
const isExpanded = this._expandedRowKey === pairKey;
return html`
<tr
class="clickable-row ${isFaint
? 'faint-row'
: ''} ${isExpanded ? 'row-expanded' : ''}"
@click=${() => this._toggleRow(pairKey)}
>
<td>
<a
href="${pair.issue1.meta_data?.url ||
pair.issue1.url}"
target="_blank"
rel="noopener noreferrer"
class="issue-id-link"
@click=${(e: Event) => e.stopPropagation()}
>
<strong class="issue-id"
>${pair.issue1.key}</strong
>
<sl-badge
pill
variant=${getStatusVariant(
pair.issue1.status
)}
>${pair.issue1.status}</sl-badge
>
</a>
<div class="issue-title">
${pair.issue1.title}
</div>
</td>
<td>
<a
href="${pair.issue2.meta_data?.url ||
pair.issue2.url}"
target="_blank"
rel="noopener noreferrer"
class="issue-id-link"
@click=${(e: Event) => e.stopPropagation()}
>
<strong class="issue-id"
>${pair.issue2.key}</strong
>
<sl-badge
pill
variant=${getStatusVariant(
pair.issue2.status
)}
>${pair.issue2.status}</sl-badge
>
</a>
<div class="issue-title">
${pair.issue2.title}
</div>
</td>
<td class="text-right">
${(pair.similarity * 100).toFixed(2)}%
</td>
<td
class="text-right"
id="verdict-${pair.issue1.id}-${pair.issue2
.id}"
>
${pair.similarity >= 0.999
? html`<sl-badge
variant="warning"
style="--sl-color-warning-text: var(--sl-color-orange-50); --sl-color-warning-600: var(--sl-color-orange-700);"
>Identical</sl-badge
>`
: renderVerdict(verdict)}
</td>
<td>
<div class="actions-container">
${when(
!verdict?.resolution,
() => html`
<sl-button
size="small"
variant="primary"
@click=${(e: Event) => {
e.stopPropagation();
this._openResolveModal(pair);
}}
>Resolve</sl-button
>
<sl-tooltip
content="Dismiss this suggestion"
>
<sl-icon-button
name="x-circle"
label="Dismiss"
@click=${(e: Event) => {
e.stopPropagation();
this._handleDismiss(pair);
}}
></sl-icon-button>
</sl-tooltip>
`
)}
</div>
</td>
</tr>
${when(
isExpanded,
() => html`
<tr class="inline-detail-row">
<td colspan="5">
${this.renderDetailRow(pair)}
</td>
</tr>
`
)}
`;
})}
</tbody>
</table>
</sl-card>
<pagination-controls
.currentPage=${this._currentPage}
.hasMorePages=${this._hasMorePages}
.loading=${this._loading}
@prev-page=${this._goToPreviousPage}
@next-page=${this._goToNextPage}
></pagination-controls>
`
: html`
<sl-alert variant="primary" open>
<sl-icon slot="icon" name="info-circle"></sl-icon>
${this._hasProjects
? 'No duplicate issues found for the current filters.'
: unsafeHTML(
'No projects found. Did you <a href="/console/trackers">add a tracker</a>?'
)}
</sl-alert>
`
)}
</div>
</div>
<div class="side-column">
${when(
this._expandedRowKey,
() => {
const expandedPair = this._duplicates.find(
(p) => `${p.issue1.id}-${p.issue2.id}` === this._expandedRowKey
);
return expandedPair
? html`
<div class="side-column-detail">
<sl-card>
<div slot="header">Issue Pair Details</div>
${this.renderDetailRow(expandedPair)}
</sl-card>
</div>
`
: '';
},
() => html`
<div class="side-column-detail">
<sl-card class="full-width">
<div slot="header">Issue Pair Details</div>
<div class="placeholder-content">
<sl-icon name="info-circle"></sl-icon>
<p>Select an issue pair to see the details here.</p>
</div>
</sl-card>
</div>
`
)}
</div>
</div>
<project-filter-modal
.isOpen=${this._isFilterModalOpen}
.allProjects=${this._allProjects}
.organizations=${this._organizations}
.projects=${this._allProjects}
.selectedProjectIds=${this._selectedProjectIds}
.selectedStatus=${this._selectedStatus}
.selectedResolution=${this._selectedResolutionStatus}
@on-close=${() => (this._isFilterModalOpen = false)}
@on-apply=${this._applyFilters}
></project-filter-modal>
<resolve-issue-modal
.isOpen=${this._isResolveModalOpen}
.duplicatePair=${this._selectedPair}
@on-close=${() => (this._isResolveModalOpen = false)}
@on-resolved=${this.handleResolution}
></resolve-issue-modal>
`;
}
private _applyFilters(event: CustomEvent) {
this._selectedProjectIds = event.detail.projectIds;
this._selectedStatus = event.detail.status;
this._selectedResolutionStatus = event.detail.resolution;
this._isFilterModalOpen = false;
this._currentPage = 1; // Reset to first page
this.fetchDuplicates();
}
}
declare global {
interface HTMLElementTagNameMap {
'issues-view': IssuesView;
}
}