Files
mp-silma-ai-aide/src/main.ts
T
2026-07-24 11:02:27 -05:00

1908 lines
66 KiB
TypeScript

import {
createIcons,
Bot,
CirclePlus,
FileText,
FolderGit2,
GitBranch,
Link,
List,
ListChecks,
RefreshCw,
Send,
X
} from "lucide";
import "./styles.css";
import {
appStore,
CHROME_PROFILE_NAME,
findKnownUrlForBase,
getAllWorkspaces,
ISSUE_LABEL_OPTIONS,
type AgentProfile,
type AddUrlMode,
type AppView,
type IssueLabelOption,
type OpenIssue,
type OpenIssuesStatusFilter,
type ScreenshotAttachment,
type WorkspacePrompt
} from "./store";
import type { GiteaIssueAttachment, GiteaIssueComment, GiteaIssueSummary, GiteaRepoSummary } from "./api";
import type { KnownUrl, LocalContext, LocalFolderCandidate, LocalWorkspace } from "./localContext";
type CropHandle = "n" | "e" | "s" | "w" | "nw" | "ne" | "sw" | "se";
const status = document.querySelector<HTMLElement>("#status");
const refreshData = document.querySelector<HTMLButtonElement>("#refresh-data");
const addIssueButton = document.querySelector<HTMLButtonElement>("#add-issue");
const manageViewSelect = document.querySelector<HTMLSelectElement>("#manage-view-select");
const viewHeader = document.querySelector<HTMLElement>("#view-header");
const viewContent = document.querySelector<HTMLElement>("#view-content");
const viewButtons = Array.from(document.querySelectorAll<HTMLButtonElement>("[data-view]"));
const lucideIcons = {
Bot,
CirclePlus,
FileText,
FolderGit2,
GitBranch,
Link,
List,
ListChecks,
RefreshCw,
Send,
X
};
function hydrateIcons(): void {
createIcons({
icons: lucideIcons
});
}
hydrateIcons();
function escapeHtml(value: string): string {
return value.replace(/[&<>"']/g, (char) => {
const replacements: Record<string, string> = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
"\"": "&quot;",
"'": "&#39;"
};
return replacements[char];
});
}
function formatDate(value: string, emptyText = "No date"): string {
if (!value) {
return emptyText;
}
return new Intl.DateTimeFormat(undefined, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit"
}).format(new Date(value));
}
function formatSha(value: string): string {
return value ? value.slice(0, 8) : "unknown";
}
function formatBytes(value: number): string {
if (!value) {
return "";
}
if (value < 1024) {
return `${value} B`;
}
if (value < 1024 * 1024) {
return `${Math.round(value / 102.4) / 10} KB`;
}
return `${Math.round(value / 1024 / 102.4) / 10} MB`;
}
function allUrls(): KnownUrl[] {
const state = appStore.getState();
const deletedBases = new Set(state.deletedUrlBases);
const urls = [...(state.localContext?.knownUrls ?? []), ...state.userUrls].filter(
(knownUrl) => !deletedBases.has(baseUrlFor(knownUrl.url))
);
return urls.filter(
(knownUrl, index) => urls.findIndex((item) => baseUrlFor(item.url) === baseUrlFor(knownUrl.url)) === index
);
}
function baseUrlFor(value: string): string {
try {
const url = new URL(value);
if (url.protocol !== "http:" && url.protocol !== "https:") {
return "";
}
return `${url.origin}/`;
} catch {
return "";
}
}
function isActiveBaseUrlKnown(): boolean {
return Boolean(findKnownUrlForBase(appStore.getState()));
}
function getActiveKnownUrl(): KnownUrl | null {
return findKnownUrlForBase(appStore.getState());
}
function canAddIssue(): boolean {
return Boolean(getActiveKnownUrl()?.giteaRepo);
}
function getRepoWorkspaceLink(repo: GiteaRepoSummary): string {
const context = appStore.getState().localContext;
const worktree = context?.gitWorktrees.find((item) => item.giteaRepo?.fullName === repo.fullName);
if (!worktree) {
return `<span class="muted">No workspace link</span>`;
}
return `<span class="path-text">./${escapeHtml(worktree.relativePath)}</span>`;
}
function renderRepos(repos: GiteaRepoSummary[]): string {
if (!repos.length) {
return `<div class="empty-state">No Gitea repos loaded yet.</div>`;
}
return `
<div class="repo-list">
${repos
.map(
(repo) => `
<article class="repo-row">
<div class="repo-main">
<a class="item-title" href="${repo.webUrl}" target="_blank" rel="noreferrer">${escapeHtml(repo.fullName)}</a>
<span class="commit-message">${escapeHtml(repo.lastCommitMessage)}</span>
</div>
<span class="repo-meta">${formatDate(repo.lastCommitAt, "No commit date")}</span>
<span class="repo-meta">${formatSha(repo.lastCommitSha)}</span>
<span class="repo-link-cell">${getRepoWorkspaceLink(repo)}</span>
</article>
`
)
.join("")}
</div>
`;
}
function renderWorkspaces(workspaces: LocalWorkspace[]): string {
const state = appStore.getState();
const userWorkspacePaths = new Set(state.userWorkspaces.map((workspace) => workspace.path));
const workspaceList = workspaces.length
? `
<div class="compact-list">
${workspaces
.map((workspace) => {
const links = workspace.linkedRepos.length
? workspace.linkedRepos
.map(
(repo) =>
`<a class="repo-link" href="${repo.webUrl}" target="_blank" rel="noreferrer">${escapeHtml(repo.fullName)}</a>`
)
.join("")
: `<span class="muted">No Gitea repo link established</span>`;
return `
<article class="workspace-row">
<div class="row-main">
<h2 class="item-title">${escapeHtml(workspace.name)}</h2>
<p class="path-text">${escapeHtml(workspace.path)}</p>
</div>
<div class="repo-link-list">${links}</div>
<div class="workspace-actions">
${
userWorkspacePaths.has(workspace.path)
? `<button class="danger-button compact-button" type="button" data-delete-user-workspace="${escapeHtml(workspace.path)}">Delete</button>`
: `<span class="muted">Generated</span>`
}
</div>
</article>
`;
})
.join("")}
</div>
`
: `<div class="empty-state">No workspaces found.</div>`;
return `
<div class="view-action-row">
<button id="open-workspace-dialog" class="secondary-button compact-button" type="button">
Add workspace
</button>
</div>
${workspaceList}
`;
}
function renderAgents(agents: AgentProfile[]): string {
const state = appStore.getState();
const draft = state.agentDraft;
return `
<div class="compact-list">
${agents
.map(
(agent) => `
<article class="agent-row">
<div class="row-main">
<h2 class="item-title">${escapeHtml(agent.name)}</h2>
<span class="path-text">${escapeHtml(agent.file)}</span>
<span class="muted">${escapeHtml(agent.description)}</span>
</div>
<div class="agent-actions">
<button class="secondary-button compact-button" type="button" data-edit-agent="${agent.id}">Edit</button>
<button class="danger-button compact-button" type="button" data-delete-agent="${agent.id}">Delete</button>
</div>
</article>
`
)
.join("")}
</div>
<article class="item-card agent-editor-card">
<div class="form-stack">
<label>
<span>Name</span>
<input id="agent-name" type="text" value="${escapeHtml(draft.name)}" placeholder="GLOBAL AGENT" />
</label>
<label>
<span>File</span>
<input id="agent-file" type="text" value="${escapeHtml(draft.file)}" placeholder="AGENTS.md" />
</label>
<label>
<span>Description</span>
<input id="agent-description" type="text" value="${escapeHtml(draft.description)}" placeholder="Agent handoff rules" />
</label>
<label>
<span>Content</span>
<textarea id="agent-content" rows="14" placeholder="Markdown content">${escapeHtml(draft.content)}</textarea>
</label>
<div class="agent-editor-actions">
<button id="new-agent" class="secondary-button" type="button">New</button>
<button id="save-agent" class="primary-button" type="button">${state.editingAgentId ? "Save agent" : "Add agent"}</button>
</div>
</div>
</article>
`;
}
function workspacePathOptions(workspaces: LocalWorkspace[], selectedPath: string): string {
if (!workspaces.length) {
return selectedPath
? `<option value="${escapeHtml(selectedPath)}" selected>${escapeHtml(selectedPath)}</option>`
: "";
}
return workspaces
.map(
(workspace) =>
`<option value="${escapeHtml(workspace.path)}"${selectedAttribute(workspace.path, selectedPath)}>${escapeHtml(workspace.name)} - ${escapeHtml(workspace.path)}</option>`
)
.join("");
}
function renderPrompts(prompts: WorkspacePrompt[]): string {
const state = appStore.getState();
const draft = state.promptDraft;
return `
<div class="compact-list">
${
prompts.length
? prompts
.map(
(prompt) => `
<article class="prompt-row">
<div class="row-main">
<h2 class="item-title">${escapeHtml(prompt.name)}</h2>
<span class="path-text">${escapeHtml(prompt.workspacePath)}</span>
</div>
<div class="prompt-actions">
<button class="secondary-button compact-button" type="button" data-edit-prompt="${prompt.id}">Edit</button>
<button class="danger-button compact-button" type="button" data-delete-prompt="${prompt.id}">Delete</button>
</div>
</article>
`
)
.join("")
: `<div class="empty-state">No workspace prompts saved yet.</div>`
}
</div>
<article class="item-card prompt-editor-card">
<div class="form-stack">
<label>
<span>Name</span>
<input id="prompt-name" type="text" value="${escapeHtml(draft.name)}" placeholder="Default implementation prompt" />
</label>
<label>
<span>Workspace</span>
<select id="prompt-workspace">
<option value="">Pick a workspace</option>
${workspacePathOptions(getAllWorkspaces(state), draft.workspacePath)}
</select>
</label>
<label>
<span>Content</span>
<textarea id="prompt-content" rows="12" placeholder="Prompt text copied into Add Issue content">${escapeHtml(draft.content)}</textarea>
</label>
<div class="prompt-editor-actions">
<button id="new-prompt" class="secondary-button" type="button">New</button>
<button id="save-prompt" class="primary-button" type="button">${state.editingPromptId ? "Save prompt" : "Add prompt"}</button>
</div>
</div>
</article>
`;
}
function renderUrls(urls: KnownUrl[]): string {
const state = appStore.getState();
const canOpenAddUrl = Boolean(state.activeBaseUrl && !isActiveBaseUrlKnown());
const urlList = urls.length
? `
<div class="compact-list">
${urls
.map(
(knownUrl) => `
<article class="url-row">
<div class="row-main">
<h2 class="item-title">${escapeHtml(knownUrl.label)}</h2>
<a class="url-text" href="${knownUrl.url}" target="_blank" rel="noreferrer">${escapeHtml(knownUrl.url)}</a>
<span class="path-text">${escapeHtml(knownUrl.workspacePath)}</span>
${
knownUrl.giteaRepo
? `<a class="repo-link" href="${knownUrl.giteaRepo.webUrl}" target="_blank" rel="noreferrer">${escapeHtml(knownUrl.giteaRepo.fullName)}</a>`
: `<span class="muted">No Gitea repo link established</span>`
}
</div>
<div class="url-actions">
<button class="visit-button" type="button" data-open-url="${knownUrl.url}">Quick visit</button>
<button class="danger-button" type="button" data-delete-url="${knownUrl.url}">Delete</button>
</div>
</article>
`
)
.join("")}
</div>
`
: `<div class="empty-state">No known URLs registered.</div>`;
return `
<div class="view-action-row">
<button id="open-add-url-dialog" class="secondary-button compact-button" type="button" ${canOpenAddUrl ? "" : "disabled"}>
Add URL
</button>
<span class="muted">${escapeHtml(state.activeBaseUrl || "Open an http or https tab to add a URL.")}</span>
</div>
${urlList}
`;
}
function selectedAttribute(value: string, selectedValue: string): string {
return value === selectedValue ? " selected" : "";
}
function workspaceOptions(workspaces: LocalWorkspace[], selectedValue: string): string {
return workspaces
.map(
(workspace) =>
`<option value="${escapeHtml(workspace.path)}"${workspace.path === selectedValue || workspace.name === selectedValue ? " selected" : ""}>${escapeHtml(workspace.name)}</option>`
)
.join("");
}
function repoOptions(context: LocalContext, selectedValue: string): string {
const repos = context.gitWorktrees
.map((worktree) => worktree.giteaRepo)
.filter((repo): repo is NonNullable<typeof repo> => Boolean(repo))
.filter((repo, index, repos) => repos.findIndex((item) => item.fullName === repo.fullName) === index)
.sort((a, b) => a.fullName.localeCompare(b.fullName));
return repos
.map(
(repo) =>
`<option value="${escapeHtml(repo.fullName)}"${selectedAttribute(repo.fullName, selectedValue)}>${escapeHtml(repo.fullName)}</option>`
)
.join("");
}
function renderAddUrl(): string {
const state = appStore.getState();
const context = state.localContext;
if (!state.activeBaseUrl) {
return `<div class="empty-state">Open an http or https tab before adding a URL link.</div>`;
}
if (isActiveBaseUrlKnown()) {
return `<div class="empty-state">${escapeHtml(state.activeBaseUrl)} is already linked.</div>`;
}
if (!context) {
return `<div class="empty-state">Workspace context has not loaded yet.</div>`;
}
const mode = state.addUrlDraft.mode;
const selectedValue = state.addUrlDraft.selectedValue;
const workspaces = getAllWorkspaces(state);
return `
<article class="item-card">
<div class="form-stack">
<label>
<span>Base URL</span>
<input type="text" value="${escapeHtml(state.activeBaseUrl)}" readonly />
</label>
<fieldset>
<legend>Link by</legend>
<label class="radio-row">
<input type="radio" name="add-url-mode" value="workspace" ${mode === "workspace" ? "checked" : ""} />
<span>Workspace</span>
</label>
<label class="radio-row">
<input type="radio" name="add-url-mode" value="repo" ${mode === "repo" ? "checked" : ""} />
<span>Gitea repo</span>
</label>
</fieldset>
<label>
<span>${mode === "repo" ? "Gitea repo" : "Workspace"}</span>
<select id="add-url-selection" ${mode ? "" : "disabled"}>
<option value="">Pick ${mode === "repo" ? "a repo" : "a workspace"}</option>
${mode === "repo" ? repoOptions(context, selectedValue) : workspaceOptions(workspaces, selectedValue)}
</select>
</label>
<button id="save-url-link" class="primary-button" type="button" ${state.addUrlDraft.selectedValue ? "" : "disabled"}>
Link URL
</button>
</div>
</article>
`;
}
function workspaceFolderCandidates(): LocalFolderCandidate[] {
const state = appStore.getState();
const workspaceCandidates = getAllWorkspaces(state).map((workspace) => ({
name: workspace.name,
path: workspace.path,
source: "workspace-root" as const
}));
const contextCandidates = state.localContext?.folderCandidates ?? [];
const candidates = [...workspaceCandidates, ...contextCandidates];
return candidates
.filter((candidate, index) => candidates.findIndex((item) => item.path === candidate.path) === index)
.sort((a, b) => a.path.localeCompare(b.path));
}
function renderWorkspaceSearchResults(candidates: LocalFolderCandidate[], query: string): string {
const normalizedQuery = query.trim().toLowerCase();
const matches = (normalizedQuery
? candidates.filter(
(candidate) =>
candidate.name.toLowerCase().includes(normalizedQuery) ||
candidate.path.toLowerCase().includes(normalizedQuery)
)
: candidates
).slice(0, 40);
if (!matches.length) {
return `<div class="empty-state">No matching folders found in the generated folder index.</div>`;
}
return `
<div class="folder-result-list">
${matches
.map(
(candidate) => `
<button class="folder-result" type="button" data-select-workspace-folder="${escapeHtml(candidate.path)}" data-folder-name="${escapeHtml(candidate.name)}">
<span>${escapeHtml(candidate.name)}</span>
<span class="path-text">${escapeHtml(candidate.path)}</span>
</button>
`
)
.join("")}
</div>
`;
}
function renderWorkspaceDialog(): string {
const state = appStore.getState();
const draft = state.workspaceDraft;
return `
<div class="form-stack">
<label>
<span>Folder search</span>
<input id="workspace-search" type="text" value="${escapeHtml(draft.search)}" placeholder="Search folders by name or path" />
</label>
<div id="workspace-folder-results">
${renderWorkspaceSearchResults(workspaceFolderCandidates(), draft.search)}
</div>
<label>
<span>Name</span>
<input id="workspace-name" type="text" value="${escapeHtml(draft.name)}" placeholder="Workspace name" />
</label>
<label>
<span>Selected folder</span>
<input id="workspace-path" type="text" value="${escapeHtml(draft.path)}" readonly placeholder="Select a folder from search results" />
</label>
<button id="save-workspace" class="primary-button" type="button" ${draft.path.trim() ? "" : "disabled"}>Add workspace</button>
</div>
`;
}
function renderActiveDialog(): string {
const state = appStore.getState();
if (!state.activeDialog) {
return "";
}
const title = state.activeDialog === "add-url" ? "Add URL" : "Add workspace";
const body = state.activeDialog === "add-url" ? renderAddUrl() : renderWorkspaceDialog();
return `
<div class="app-modal" role="dialog" aria-modal="true" aria-label="${escapeHtml(title)}">
<article class="app-modal-panel">
<div class="app-modal-heading">
<h2>${escapeHtml(title)}</h2>
<button class="secondary-button compact-button icon-only-button" type="button" data-close-app-dialog title="Close" aria-label="Close">
<i data-lucide="x"></i>
</button>
</div>
${body}
</article>
</div>
`;
}
function renderFileList(files: File[], target: "issue" | "comment" | "issue-attachment"): string {
if (!files.length) {
return `<span class="muted">No files selected.</span>`;
}
const attribute =
target === "issue"
? "data-delete-issue-file"
: target === "comment"
? "data-delete-comment-file"
: "data-delete-issue-asset-file";
return `
<div class="file-list">
${files
.map(
(file, index) => `
<div class="file-row">
<span>${escapeHtml(file.name)}</span>
<span>${escapeHtml(formatBytes(file.size))}</span>
<button class="danger-button compact-button" type="button" ${attribute}="${index}">Delete</button>
</div>
`
)
.join("")}
</div>
`;
}
function renderAgentOptions(agents: AgentProfile[], selectedAgentId: string): string {
return agents
.map(
(agent) =>
`<option value="${escapeHtml(agent.id)}" ${agent.id === selectedAgentId ? "selected" : ""}>${escapeHtml(agent.name)} (${escapeHtml(agent.file)})</option>`
)
.join("");
}
function renderIssuePromptOptions(prompts: WorkspacePrompt[], selectedPromptId: string): string {
return `
<option value="">No default prompt</option>
${prompts
.map(
(prompt) =>
`<option value="${escapeHtml(prompt.id)}"${prompt.id === selectedPromptId ? " selected" : ""}>${escapeHtml(prompt.name)}</option>`
)
.join("")}
`;
}
function renderLabelOptions(selectedLabels: IssueLabelOption[], target: "draft" | "edit"): string {
return `
<fieldset class="label-fieldset">
<legend>Labels</legend>
<div class="label-option-grid">
${ISSUE_LABEL_OPTIONS.map(
(label) => `
<label class="checkbox-row label-option">
<input type="checkbox" value="${escapeHtml(label)}" data-label-target="${target}" ${selectedLabels.includes(label) ? "checked" : ""} />
<span>${escapeHtml(label)}</span>
</label>
`
).join("")}
</div>
</fieldset>
`;
}
function renderIssueLabelFilter(selectedLabel: IssueLabelOption | ""): string {
return `
<label class="compact-select-label">
<span>Label</span>
<select id="issues-label-filter">
<option value="">All labels</option>
${ISSUE_LABEL_OPTIONS.map(
(label) => `<option value="${escapeHtml(label)}"${label === selectedLabel ? " selected" : ""}>${escapeHtml(label)}</option>`
).join("")}
</select>
</label>
`;
}
function renderIssueStatusFilters(selectedStatuses: OpenIssuesStatusFilter[]): string {
return `
<fieldset class="inline-fieldset status-filter-fieldset">
<legend>Status</legend>
<label class="checkbox-row">
<input type="checkbox" value="open" data-issues-status-filter ${selectedStatuses.includes("open") ? "checked" : ""} />
<span>Open</span>
</label>
<label class="checkbox-row">
<input type="checkbox" value="closed" data-issues-status-filter ${selectedStatuses.includes("closed") ? "checked" : ""} />
<span>Closed</span>
</label>
</fieldset>
`;
}
function renderBodyText(body: string, emptyText: string): string {
const trimmed = body.trim();
if (!trimmed) {
return `<p class="muted">${escapeHtml(emptyText)}</p>`;
}
return `<pre class="issue-body-text">${escapeHtml(trimmed)}</pre>`;
}
function renderIssueAttachments(attachments: GiteaIssueAttachment[]): string {
if (!attachments.length) {
return `<p class="muted">No issue attachments.</p>`;
}
const state = appStore.getState();
return `
<ul class="issue-attachment-list">
${attachments
.map(
(attachment) => `
<li>
<a href="${attachment.downloadUrl}" target="_blank" rel="noreferrer">${escapeHtml(attachment.name)}</a>
${attachment.size ? `<span>${escapeHtml(formatBytes(attachment.size))}</span>` : ""}
<button class="danger-button compact-button" type="button" data-delete-issue-attachment="${attachment.id}" ${state.isUpdatingIssue ? "disabled" : ""}>Delete</button>
</li>
`
)
.join("")}
</ul>
`;
}
function renderIssueComments(comments: GiteaIssueComment[]): string {
if (!comments.length) {
return `<p class="muted">No comments yet.</p>`;
}
return `
<div class="issue-comment-chain">
${comments
.map(
(comment) => `
<article class="issue-comment">
<div class="issue-comment-meta">
<span>${escapeHtml(comment.author)}</span>
<a href="${comment.webUrl}" target="_blank" rel="noreferrer">${escapeHtml(formatDate(comment.createdAt))}</a>
</div>
${renderBodyText(comment.body, "Empty comment.")}
</article>
`
)
.join("")}
</div>
`;
}
function renderScreenshotList(
screenshots: ScreenshotAttachment[],
deleteAction: "issue" | "comment" | "issue-attachment"
): string {
if (!screenshots.length) {
return `<span class="muted">No screenshots captured.</span>`;
}
const attribute =
deleteAction === "issue"
? "data-delete-screenshot"
: deleteAction === "comment"
? "data-delete-comment-screenshot"
: "data-delete-issue-asset-screenshot";
const previewTarget = deleteAction;
return `
<div class="screenshot-list">
${screenshots
.map(
(screenshot) => `
<figure class="screenshot-item">
<button class="screenshot-preview-button" type="button" data-preview-screenshot="${screenshot.id}" data-preview-target="${previewTarget}">
<img src="${screenshot.dataUrl}" alt="${escapeHtml(screenshot.name)}" />
</button>
<figcaption>${escapeHtml(screenshot.name)}</figcaption>
<button class="danger-button compact-button" type="button" ${attribute}="${screenshot.id}">Delete</button>
</figure>
`
)
.join("")}
</div>
`;
}
function getPreviewScreenshot(): ScreenshotAttachment | null {
const state = appStore.getState();
const preview = state.screenshotPreview;
if (!preview) {
return null;
}
const screenshots =
preview.target === "issue"
? state.issueDraft.screenshots
: preview.target === "comment"
? state.issueCommentDraft.screenshots
: state.issueAttachmentDraft.screenshots;
return screenshots.find((screenshot) => screenshot.id === preview.id) ?? null;
}
function renderScreenshotPreview(): string {
const state = appStore.getState();
const preview = state.screenshotPreview;
const screenshot = getPreviewScreenshot();
if (!preview || !screenshot) {
return "";
}
const crop = preview.crop;
return `
<div class="screenshot-modal" role="dialog" aria-modal="true" aria-label="Screenshot preview">
<div class="screenshot-modal-panel">
<div class="screenshot-modal-toolbar">
<span>${escapeHtml(screenshot.name)}</span>
<button class="secondary-button compact-button" type="button" data-close-screenshot-preview>Close</button>
</div>
<div class="crop-frame">
<img src="${screenshot.dataUrl}" alt="${escapeHtml(screenshot.name)}" />
<button class="crop-box" type="button" data-crop-drag style="left:${crop.left}%; top:${crop.top}%; width:${crop.width}%; height:${crop.height}%">
${(["nw", "n", "ne", "e", "se", "s", "sw", "w"] as CropHandle[])
.map((handle) => `<span class="crop-handle crop-handle-${handle}" data-crop-handle="${handle}"></span>`)
.join("")}
</button>
</div>
<div class="modal-actions">
<button class="primary-button" type="button" data-apply-screenshot-crop>Crop</button>
<button class="danger-button" type="button" data-remove-preview-screenshot>Remove</button>
</div>
</div>
</div>
`;
}
function renderIssueRows(issues: OpenIssue[], selectedIssueNumber: number | null, selectedRepoFullName: string): string {
const state = appStore.getState();
if (!issues.length) {
return `<div class="empty-state">No issues found.</div>`;
}
return `
<div class="issue-list">
${issues
.map(
(issue) => `
<div class="issue-row" data-selected="${issue.number === selectedIssueNumber && issue.repoFullName === selectedRepoFullName ? "true" : "false"}">
<button class="issue-select-button open-issue-select-button" type="button" data-select-issue="${issue.number}" data-issue-repo="${escapeHtml(issue.repoFullName)}">
<span class="issue-number">#${issue.number}</span>
<span class="issue-title">${escapeHtml(issue.title)}</span>
<span class="issue-repo">${escapeHtml(issue.repoFullName)}</span>
<span class="issue-state">${escapeHtml(issue.state)}</span>
<span class="issue-meta">${formatDate(issue.createdAt)}</span>
</button>
<button class="danger-button compact-button" type="button" data-delete-issue="${issue.number}" data-issue-repo="${escapeHtml(issue.repoFullName)}" ${state.isUpdatingIssue ? "disabled" : ""}>Delete</button>
</div>
`
)
.join("")}
</div>
`;
}
function renderIssueCommentForm(issue: GiteaIssueSummary): string {
const state = appStore.getState();
const draft = state.issueCommentDraft;
const canSubmit =
Boolean(draft.content.trim() || draft.screenshots.length || draft.files.length) && !state.isCreatingIssueComment;
return `
<article class="item-card issue-comment-card">
<div class="item-heading">
<a class="item-title" href="${issue.webUrl}" target="_blank" rel="noreferrer">#${issue.number} ${escapeHtml(issue.title)}</a>
<span class="issue-state">${escapeHtml(issue.state)}</span>
</div>
<div class="form-stack">
<label>
<span>Comment</span>
<textarea id="issue-comment-content" rows="6" placeholder="Add a comment">${escapeHtml(draft.content)}</textarea>
</label>
<section class="attachment-section">
<div class="attachment-heading">
<span>Screenshots</span>
<button id="capture-comment-screenshot" class="secondary-button" type="button" ${state.isCapturingIssueCommentScreenshot ? "disabled" : ""}>
${state.isCapturingIssueCommentScreenshot ? "Taking..." : "Take snapshot"}
</button>
</div>
${renderScreenshotList(draft.screenshots, "comment")}
</section>
<label>
<span>File attachments</span>
<input id="issue-comment-files" type="file" multiple />
</label>
${renderFileList(draft.files, "comment")}
<button id="submit-issue-comment" class="primary-button" type="button" ${canSubmit ? "" : "disabled"}>
${state.isCreatingIssueComment ? "Commenting..." : "Comment"}
</button>
</div>
</article>
`;
}
function renderIssueAttachmentForm(): string {
const state = appStore.getState();
const draft = state.issueAttachmentDraft;
const canUpload = Boolean(draft.screenshots.length || draft.files.length) && !state.isUploadingIssueAttachments;
return `
<section class="attachment-section issue-asset-uploader">
<div class="attachment-heading">
<span>Add attachments</span>
<button id="capture-issue-asset-screenshot" class="secondary-button compact-button" type="button" ${state.isCapturingIssueAttachmentScreenshot ? "disabled" : ""}>
${state.isCapturingIssueAttachmentScreenshot ? "Taking..." : "Take snapshot"}
</button>
</div>
${renderScreenshotList(draft.screenshots, "issue-attachment")}
<label>
<span>File attachments</span>
<input id="issue-asset-files" type="file" multiple />
</label>
${renderFileList(draft.files, "issue-attachment")}
<button id="upload-issue-assets" class="primary-button" type="button" ${canUpload ? "" : "disabled"}>
${state.isUploadingIssueAttachments ? "Uploading..." : "Upload attachments"}
</button>
</section>
`;
}
function renderSelectedIssueDetail(): string {
const state = appStore.getState();
const detail = state.selectedIssueDetail;
if (state.isLoadingIssueDetail && !detail) {
return `<div class="empty-state">Loading issue details...</div>`;
}
if (!detail) {
return `<div class="empty-state">Issue details are not loaded yet.</div>`;
}
const draft = state.selectedIssueEditDraft;
const detailLabels = ISSUE_LABEL_OPTIONS.filter((label) =>
detail.issue.labels.some((issueLabel) => issueLabel.toLowerCase() === label.toLowerCase())
);
const hasChanges =
draft.subject !== detail.issue.title ||
draft.content !== detail.issue.body ||
draft.labels.join("|") !== detailLabels.join("|");
return `
<article class="item-card issue-detail-card">
<div class="item-heading">
<a class="item-title" href="${detail.issue.webUrl}" target="_blank" rel="noreferrer">#${detail.issue.number} ${escapeHtml(detail.issue.title)}</a>
<div class="issue-detail-actions">
<a class="secondary-link" href="${detail.issue.webUrl}" target="_blank" rel="noreferrer">Open in Gitea</a>
<button id="send-selected-issue-to-codex" class="secondary-button compact-button" type="button">
<i data-lucide="send"></i>
<span>Send to Codex</span>
</button>
<button id="refresh-issue-detail" class="secondary-button compact-button icon-only-button" type="button" title="Refresh issue" aria-label="Refresh issue" ${state.isLoadingIssueDetail ? "disabled" : ""}>
<i data-lucide="refresh-cw"></i>
</button>
<button id="close-selected-issue" class="secondary-button compact-button icon-only-button" type="button" title="Close issue detail" aria-label="Close issue detail">
<i data-lucide="x"></i>
</button>
</div>
</div>
<div class="form-stack">
<label>
<span>Subject</span>
<input id="selected-issue-subject" type="text" value="${escapeHtml(draft.subject)}" />
</label>
<label>
<span>Content</span>
<textarea id="selected-issue-content" rows="8">${escapeHtml(draft.content)}</textarea>
</label>
${renderLabelOptions(draft.labels, "edit")}
<button id="save-selected-issue" class="primary-button" type="button" ${hasChanges && draft.subject.trim() && !state.isUpdatingIssue ? "" : "disabled"}>
${state.isUpdatingIssue ? "Saving issue..." : "Save issue"}
</button>
</div>
<div class="section-heading inline-heading">
<span>Attachments</span>
</div>
${renderIssueAttachments(detail.attachments)}
${renderIssueAttachmentForm()}
<div class="section-heading inline-heading">
<span>Comments</span>
</div>
${renderIssueComments(detail.comments)}
</article>
`;
}
function renderOpenIssues(): string {
const state = appStore.getState();
return `
<article class="item-card dense-card">
<div class="issue-filter-row">
<label class="checkbox-row">
<input id="limit-open-issues" type="checkbox" ${state.openIssuesLimitToCurrentRepo ? "checked" : ""} />
<span>Only current repo</span>
</label>
${renderIssueStatusFilters(state.openIssuesStatusFilters)}
${renderIssueLabelFilter(state.openIssuesLabelFilter)}
<button id="refresh-target-issues" class="secondary-button compact-button icon-only-button" type="button" title="Refresh issues" aria-label="Refresh issues" ${state.isLoadingTargetIssues ? "disabled" : ""}>
<i data-lucide="refresh-cw"></i>
</button>
</div>
${
state.openIssuesLimitToCurrentRepo && !getActiveKnownUrl()?.giteaRepo
? `<div class="empty-state">Open a known URL linked to a Gitea repo, or turn off the current repo filter.</div>`
: state.isLoadingTargetIssues && !state.openIssues.length
? `<div class="empty-state">Loading issues...</div>`
: renderIssueRows(state.openIssues, state.selectedIssueNumber, state.selectedIssueRepoFullName)
}
</article>
${
state.selectedIssueNumber
? `${renderSelectedIssueDetail()}${
state.openIssues.find(
(issue) =>
issue.number === state.selectedIssueNumber && issue.repoFullName === state.selectedIssueRepoFullName
)
? renderIssueCommentForm(
state.openIssues.find(
(issue) =>
issue.number === state.selectedIssueNumber && issue.repoFullName === state.selectedIssueRepoFullName
) as OpenIssue
)
: ""
}`
: ""
}
`;
}
function renderAddIssue(): string {
const state = appStore.getState();
const knownUrl = getActiveKnownUrl();
const draft = state.issueDraft;
if (!knownUrl) {
return `<div class="empty-state">Open a known URL before creating an issue.</div>`;
}
if (!knownUrl.giteaRepo) {
return `<div class="empty-state">${escapeHtml(knownUrl.label)} is linked to a workspace but not a Gitea repo.</div>`;
}
const workspacePrompts = state.prompts.filter((prompt) => prompt.workspacePath === knownUrl.workspacePath);
return `
<article class="item-card issue-card">
<div class="target-summary">
<span class="target-label">Target repo</span>
<a class="item-title" href="${knownUrl.giteaRepo.webUrl}" target="_blank" rel="noreferrer">${escapeHtml(knownUrl.giteaRepo.fullName)}</a>
<span class="target-label">Active URL</span>
<span class="url-text">${escapeHtml(state.activeTabUrl || "Unknown active tab")}</span>
<span class="target-label">Known URL</span>
<span class="url-text">${escapeHtml(knownUrl.url)}</span>
<span class="path-text">${escapeHtml(knownUrl.workspacePath)}</span>
<span class="muted">Chrome profile: ${escapeHtml(CHROME_PROFILE_NAME)}</span>
</div>
<div class="form-stack">
<label>
<span>Subject</span>
<input id="issue-subject" type="text" value="${escapeHtml(draft.subject)}" placeholder="Issue subject" />
</label>
<label>
<span>Default prompt</span>
<select id="issue-prompt">
${renderIssuePromptOptions(workspacePrompts, draft.promptId)}
</select>
</label>
<label>
<span>Content</span>
<textarea id="issue-content" rows="8" placeholder="Describe the issue">${escapeHtml(draft.content)}</textarea>
</label>
<label>
<span>Agent file</span>
<select id="issue-agent">
${renderAgentOptions(state.agents, draft.agentId)}
</select>
</label>
${renderLabelOptions(draft.labels, "draft")}
<section class="attachment-section">
<div class="attachment-heading">
<span>Screenshots</span>
<button id="capture-screenshot" class="secondary-button" type="button" ${state.isCapturingScreenshot ? "disabled" : ""}>
${state.isCapturingScreenshot ? "Taking..." : "Take snapshot"}
</button>
</div>
${renderScreenshotList(draft.screenshots, "issue")}
</section>
<label>
<span>File attachments</span>
<input id="issue-files" type="file" multiple />
</label>
${renderFileList(draft.files, "issue")}
<button id="create-issue" class="primary-button" type="button" ${draft.subject.trim() && !state.isCreatingIssue ? "" : "disabled"}>
${state.isCreatingIssue ? "Creating issue..." : "Create issue"}
</button>
</div>
</article>
`;
}
function openUrl(url: string): void {
if (globalThis.chrome?.tabs?.create) {
void globalThis.chrome.tabs.create({ url });
return;
}
window.open(url, "_blank", "noopener,noreferrer");
}
function bindWorkspaceFolderResultActions(): void {
viewContent?.querySelectorAll<HTMLButtonElement>("[data-select-workspace-folder]").forEach((button) => {
button.addEventListener("click", () => {
const path = button.dataset.selectWorkspaceFolder;
const name = button.dataset.folderName;
if (path) {
appStore.getState().setWorkspaceDraft({
path,
name: appStore.getState().workspaceDraft.name || name || ""
});
}
});
});
}
function bindViewActions(): void {
viewContent?.querySelector<HTMLButtonElement>("#open-add-url-dialog")?.addEventListener("click", () => {
appStore.getState().openDialog("add-url");
});
viewContent?.querySelector<HTMLButtonElement>("#open-workspace-dialog")?.addEventListener("click", () => {
appStore.getState().openDialog("add-workspace");
});
viewContent?.querySelector<HTMLButtonElement>("[data-close-app-dialog]")?.addEventListener("click", () => {
appStore.getState().closeDialog();
});
viewContent?.querySelector<HTMLInputElement>("#agent-name")?.addEventListener("input", (event) => {
appStore.getState().setAgentDraft({
name: (event.target as HTMLInputElement).value
});
});
viewContent?.querySelector<HTMLInputElement>("#agent-file")?.addEventListener("input", (event) => {
appStore.getState().setAgentDraft({
file: (event.target as HTMLInputElement).value
});
});
viewContent?.querySelector<HTMLInputElement>("#agent-description")?.addEventListener("input", (event) => {
appStore.getState().setAgentDraft({
description: (event.target as HTMLInputElement).value
});
});
viewContent?.querySelector<HTMLTextAreaElement>("#agent-content")?.addEventListener("input", (event) => {
appStore.getState().setAgentDraft({
content: (event.target as HTMLTextAreaElement).value
});
});
viewContent?.querySelector<HTMLButtonElement>("#new-agent")?.addEventListener("click", () => {
appStore.getState().startNewAgent();
});
viewContent?.querySelector<HTMLButtonElement>("#save-agent")?.addEventListener("click", () => {
void appStore.getState().saveAgent();
});
viewContent?.querySelectorAll<HTMLButtonElement>("[data-edit-agent]").forEach((button) => {
button.addEventListener("click", () => {
const id = button.dataset.editAgent;
if (id) {
appStore.getState().editAgent(id);
}
});
});
viewContent?.querySelectorAll<HTMLButtonElement>("[data-delete-agent]").forEach((button) => {
button.addEventListener("click", () => {
const id = button.dataset.deleteAgent;
if (id && window.confirm("Delete this agent?")) {
void appStore.getState().deleteAgent(id);
}
});
});
viewContent?.querySelector<HTMLInputElement>("#workspace-name")?.addEventListener("input", (event) => {
appStore.getState().setWorkspaceDraft({
name: (event.target as HTMLInputElement).value
});
});
viewContent?.querySelector<HTMLInputElement>("#workspace-search")?.addEventListener("input", (event) => {
appStore.getState().setWorkspaceDraft({
search: (event.target as HTMLInputElement).value
});
});
bindWorkspaceFolderResultActions();
viewContent?.querySelector<HTMLButtonElement>("#save-workspace")?.addEventListener("click", () => {
void appStore.getState().saveWorkspace();
});
viewContent?.querySelectorAll<HTMLButtonElement>("[data-delete-user-workspace]").forEach((button) => {
button.addEventListener("click", () => {
const path = button.dataset.deleteUserWorkspace;
if (path && window.confirm("Delete this user-added workspace?")) {
void appStore.getState().deleteUserWorkspace(path);
}
});
});
viewContent?.querySelector<HTMLInputElement>("#prompt-name")?.addEventListener("input", (event) => {
appStore.getState().setPromptDraft({
name: (event.target as HTMLInputElement).value
});
});
viewContent?.querySelector<HTMLSelectElement>("#prompt-workspace")?.addEventListener("change", (event) => {
appStore.getState().setPromptDraft({
workspacePath: (event.target as HTMLSelectElement).value
});
});
viewContent?.querySelector<HTMLTextAreaElement>("#prompt-content")?.addEventListener("input", (event) => {
appStore.getState().setPromptDraft({
content: (event.target as HTMLTextAreaElement).value
});
});
viewContent?.querySelector<HTMLButtonElement>("#new-prompt")?.addEventListener("click", () => {
appStore.getState().startNewPrompt();
});
viewContent?.querySelector<HTMLButtonElement>("#save-prompt")?.addEventListener("click", () => {
void appStore.getState().savePrompt();
});
viewContent?.querySelectorAll<HTMLButtonElement>("[data-edit-prompt]").forEach((button) => {
button.addEventListener("click", () => {
const id = button.dataset.editPrompt;
if (id) {
appStore.getState().editPrompt(id);
}
});
});
viewContent?.querySelectorAll<HTMLButtonElement>("[data-delete-prompt]").forEach((button) => {
button.addEventListener("click", () => {
const id = button.dataset.deletePrompt;
if (id && window.confirm("Delete this prompt?")) {
void appStore.getState().deletePrompt(id);
}
});
});
viewContent?.querySelectorAll<HTMLButtonElement>("[data-open-url]").forEach((button) => {
button.addEventListener("click", () => {
const url = button.dataset.openUrl;
if (url) {
openUrl(url);
}
});
});
viewContent?.querySelectorAll<HTMLButtonElement>("[data-delete-url]").forEach((button) => {
button.addEventListener("click", () => {
const url = button.dataset.deleteUrl;
if (url) {
void appStore.getState().deleteUrlLink(url);
}
});
});
viewContent?.querySelectorAll<HTMLInputElement>("input[name='add-url-mode']").forEach((input) => {
input.addEventListener("change", () => {
appStore.getState().setAddUrlDraft({
mode: input.value as AddUrlMode,
selectedValue: ""
});
});
});
viewContent?.querySelector<HTMLSelectElement>("#add-url-selection")?.addEventListener("change", (event) => {
appStore.getState().setAddUrlDraft({
selectedValue: (event.target as HTMLSelectElement).value
});
});
viewContent?.querySelector<HTMLButtonElement>("#save-url-link")?.addEventListener("click", () => {
void appStore.getState().saveCurrentUrlLink();
});
viewContent?.querySelector<HTMLInputElement>("#limit-open-issues")?.addEventListener("change", (event) => {
appStore.getState().setOpenIssuesLimit((event.target as HTMLInputElement).checked);
});
viewContent?.querySelectorAll<HTMLInputElement>("[data-issues-status-filter]").forEach((input) => {
input.addEventListener("change", () => {
const statusFilters = Array.from(
viewContent.querySelectorAll<HTMLInputElement>("[data-issues-status-filter]:checked")
).map((checkedInput) => checkedInput.value as OpenIssuesStatusFilter);
appStore.getState().setOpenIssuesStatusFilters(statusFilters);
});
});
viewContent?.querySelector<HTMLSelectElement>("#issues-label-filter")?.addEventListener("change", (event) => {
appStore.getState().setOpenIssuesLabelFilter((event.target as HTMLSelectElement).value as IssueLabelOption | "");
});
viewContent?.querySelector<HTMLInputElement>("#issue-subject")?.addEventListener("input", (event) => {
appStore.getState().setIssueDraft({
subject: (event.target as HTMLInputElement).value
});
});
viewContent?.querySelector<HTMLTextAreaElement>("#issue-content")?.addEventListener("input", (event) => {
appStore.getState().setIssueDraft({
content: (event.target as HTMLTextAreaElement).value
});
});
viewContent?.querySelector<HTMLSelectElement>("#issue-prompt")?.addEventListener("change", (event) => {
appStore.getState().setIssuePrompt((event.target as HTMLSelectElement).value);
});
viewContent?.querySelector<HTMLSelectElement>("#issue-agent")?.addEventListener("change", (event) => {
appStore.getState().setIssueDraft({
agentId: (event.target as HTMLSelectElement).value
});
});
viewContent?.querySelector<HTMLInputElement>("#issue-files")?.addEventListener("change", (event) => {
const input = event.target as HTMLInputElement;
appStore.getState().setIssueFiles(Array.from(input.files ?? []));
input.value = "";
});
viewContent?.querySelector<HTMLButtonElement>("#capture-screenshot")?.addEventListener("click", () => {
void appStore.getState().captureScreenshot();
});
viewContent?.querySelectorAll<HTMLButtonElement>("[data-delete-screenshot]").forEach((button) => {
button.addEventListener("click", () => {
const id = button.dataset.deleteScreenshot;
if (id) {
appStore.getState().deleteIssueScreenshot(id);
}
});
});
viewContent?.querySelectorAll<HTMLButtonElement>("[data-delete-issue-file]").forEach((button) => {
button.addEventListener("click", () => {
const index = Number(button.dataset.deleteIssueFile);
if (Number.isInteger(index)) {
appStore.getState().deleteIssueFile(index);
}
});
});
viewContent?.querySelectorAll<HTMLButtonElement>("[data-preview-screenshot]").forEach((button) => {
button.addEventListener("click", () => {
const id = button.dataset.previewScreenshot;
const target = button.dataset.previewTarget;
if (id && (target === "issue" || target === "comment" || target === "issue-attachment")) {
appStore.getState().openScreenshotPreview(target, id);
}
});
});
viewContent?.querySelectorAll<HTMLButtonElement>("[data-select-issue]").forEach((button) => {
button.addEventListener("click", () => {
const issueNumber = Number(button.dataset.selectIssue);
const repoFullName = button.dataset.issueRepo;
if (Number.isFinite(issueNumber)) {
appStore.getState().selectIssue(issueNumber, repoFullName);
}
});
});
viewContent?.querySelectorAll<HTMLButtonElement>("[data-delete-issue]").forEach((button) => {
button.addEventListener("click", () => {
const issueNumber = Number(button.dataset.deleteIssue);
const repoFullName = button.dataset.issueRepo;
if (Number.isFinite(issueNumber) && window.confirm(`Delete issue #${issueNumber}?`)) {
void appStore.getState().deleteIssue(issueNumber, repoFullName);
}
});
});
viewContent?.querySelector<HTMLButtonElement>("#refresh-target-issues")?.addEventListener("click", () => {
void appStore.getState().loadOpenIssues(true);
});
viewContent?.querySelector<HTMLButtonElement>("#refresh-issue-detail")?.addEventListener("click", () => {
void appStore.getState().loadSelectedIssueDetail(true);
});
viewContent?.querySelector<HTMLButtonElement>("#close-selected-issue")?.addEventListener("click", () => {
appStore.getState().clearSelectedIssue();
});
viewContent?.querySelector<HTMLButtonElement>("#send-selected-issue-to-codex")?.addEventListener("click", () => {
const state = appStore.getState();
const selectedIssue = state.openIssues.find(
(issue) => issue.number === state.selectedIssueNumber && issue.repoFullName === state.selectedIssueRepoFullName
);
console.log("Send to Codex", {
issue: state.selectedIssueDetail?.issue ?? selectedIssue ?? null,
repoFullName: state.selectedIssueRepoFullName,
knownUrl: selectedIssue?.knownUrl ?? null,
attachments: state.selectedIssueDetail?.attachments ?? [],
comments: state.selectedIssueDetail?.comments ?? []
});
});
viewContent?.querySelector<HTMLInputElement>("#selected-issue-subject")?.addEventListener("input", (event) => {
appStore.getState().setSelectedIssueEditDraft({
subject: (event.target as HTMLInputElement).value
});
});
viewContent?.querySelector<HTMLTextAreaElement>("#selected-issue-content")?.addEventListener("input", (event) => {
appStore.getState().setSelectedIssueEditDraft({
content: (event.target as HTMLTextAreaElement).value
});
});
viewContent?.querySelector<HTMLButtonElement>("#save-selected-issue")?.addEventListener("click", () => {
void appStore.getState().saveSelectedIssueEdits();
});
viewContent?.querySelectorAll<HTMLInputElement>("[data-label-target]").forEach((input) => {
input.addEventListener("change", () => {
const target = input.dataset.labelTarget;
const labels = Array.from(
viewContent.querySelectorAll<HTMLInputElement>(`input[data-label-target='${target}']:checked`)
).map((checkedInput) => checkedInput.value as IssueLabelOption);
if (target === "draft") {
appStore.getState().setIssueDraft({ labels });
} else if (target === "edit") {
appStore.getState().setSelectedIssueEditDraft({ labels });
}
});
});
viewContent?.querySelectorAll<HTMLButtonElement>("[data-delete-issue-attachment]").forEach((button) => {
button.addEventListener("click", () => {
const attachmentId = Number(button.dataset.deleteIssueAttachment);
if (Number.isFinite(attachmentId) && window.confirm("Delete this attachment from the issue?")) {
void appStore.getState().deleteSelectedIssueAttachment(attachmentId);
}
});
});
viewContent?.querySelector<HTMLInputElement>("#issue-asset-files")?.addEventListener("change", (event) => {
const input = event.target as HTMLInputElement;
appStore.getState().setIssueAttachmentFiles(Array.from(input.files ?? []));
input.value = "";
});
viewContent?.querySelector<HTMLButtonElement>("#capture-issue-asset-screenshot")?.addEventListener("click", () => {
void appStore.getState().captureIssueAttachmentScreenshot();
});
viewContent?.querySelectorAll<HTMLButtonElement>("[data-delete-issue-asset-screenshot]").forEach((button) => {
button.addEventListener("click", () => {
const id = button.dataset.deleteIssueAssetScreenshot;
if (id) {
appStore.getState().deleteIssueAttachmentScreenshot(id);
}
});
});
viewContent?.querySelectorAll<HTMLButtonElement>("[data-delete-issue-asset-file]").forEach((button) => {
button.addEventListener("click", () => {
const index = Number(button.dataset.deleteIssueAssetFile);
if (Number.isInteger(index)) {
appStore.getState().deleteIssueAttachmentFile(index);
}
});
});
viewContent?.querySelector<HTMLButtonElement>("#upload-issue-assets")?.addEventListener("click", () => {
void appStore.getState().uploadSelectedIssueAttachments();
});
viewContent?.querySelector<HTMLTextAreaElement>("#issue-comment-content")?.addEventListener("input", (event) => {
appStore.getState().setIssueCommentDraft({
content: (event.target as HTMLTextAreaElement).value
});
});
viewContent?.querySelector<HTMLInputElement>("#issue-comment-files")?.addEventListener("change", (event) => {
const input = event.target as HTMLInputElement;
appStore.getState().setIssueCommentFiles(Array.from(input.files ?? []));
input.value = "";
});
viewContent?.querySelector<HTMLButtonElement>("#capture-comment-screenshot")?.addEventListener("click", () => {
void appStore.getState().captureIssueCommentScreenshot();
});
viewContent?.querySelectorAll<HTMLButtonElement>("[data-delete-comment-screenshot]").forEach((button) => {
button.addEventListener("click", () => {
const id = button.dataset.deleteCommentScreenshot;
if (id) {
appStore.getState().deleteIssueCommentScreenshot(id);
}
});
});
viewContent?.querySelectorAll<HTMLButtonElement>("[data-delete-comment-file]").forEach((button) => {
button.addEventListener("click", () => {
const index = Number(button.dataset.deleteCommentFile);
if (Number.isInteger(index)) {
appStore.getState().deleteIssueCommentFile(index);
}
});
});
bindCropInteractions();
viewContent?.querySelector<HTMLButtonElement>("[data-close-screenshot-preview]")?.addEventListener("click", () => {
appStore.getState().closeScreenshotPreview();
});
viewContent?.querySelector<HTMLButtonElement>("[data-remove-preview-screenshot]")?.addEventListener("click", () => {
appStore.getState().removePreviewScreenshot();
});
viewContent?.querySelector<HTMLButtonElement>("[data-apply-screenshot-crop]")?.addEventListener("click", () => {
void appStore.getState().applyScreenshotCrop();
});
viewContent?.querySelector<HTMLButtonElement>("#submit-issue-comment")?.addEventListener("click", () => {
void appStore.getState().createIssueComment();
});
viewContent?.querySelector<HTMLButtonElement>("#create-issue")?.addEventListener("click", () => {
void appStore.getState().createIssue();
});
}
function bindCropInteractions(): void {
const frame = viewContent?.querySelector<HTMLElement>(".crop-frame");
const cropBox = viewContent?.querySelector<HTMLButtonElement>("[data-crop-drag]");
if (!frame || !cropBox) {
return;
}
cropBox.addEventListener("pointerdown", (event) => {
const state = appStore.getState();
const preview = state.screenshotPreview;
if (!preview) {
return;
}
event.preventDefault();
cropBox.setPointerCapture(event.pointerId);
const handle = (event.target as HTMLElement).dataset.cropHandle as CropHandle | undefined;
const frameRect = frame.getBoundingClientRect();
const startX = event.clientX;
const startY = event.clientY;
const startCrop = { ...preview.crop };
const updateCrop = (moveEvent: PointerEvent): void => {
const deltaX = ((moveEvent.clientX - startX) / frameRect.width) * 100;
const deltaY = ((moveEvent.clientY - startY) / frameRect.height) * 100;
if (!handle) {
appStore.getState().setScreenshotPreviewCrop({
left: startCrop.left + deltaX,
top: startCrop.top + deltaY
});
return;
}
const nextCrop = { ...startCrop };
if (handle.includes("e")) {
nextCrop.width = startCrop.width + deltaX;
}
if (handle.includes("s")) {
nextCrop.height = startCrop.height + deltaY;
}
if (handle.includes("w")) {
nextCrop.left = startCrop.left + deltaX;
nextCrop.width = startCrop.width - deltaX;
}
if (handle.includes("n")) {
nextCrop.top = startCrop.top + deltaY;
nextCrop.height = startCrop.height - deltaY;
}
appStore.getState().setScreenshotPreviewCrop(nextCrop);
};
const stopCrop = (): void => {
window.removeEventListener("pointermove", updateCrop);
window.removeEventListener("pointerup", stopCrop);
window.removeEventListener("pointercancel", stopCrop);
};
window.addEventListener("pointermove", updateCrop);
window.addEventListener("pointerup", stopCrop);
window.addEventListener("pointercancel", stopCrop);
});
}
function renderView(): void {
if (!viewHeader || !viewContent) {
return;
}
const state = appStore.getState();
const context = state.localContext;
const titles: Record<AppView, string> = {
"add-issue": "Add issue",
"add-url": "Add URL",
agents: "Agents",
"open-issues": "Issues",
prompts: "Prompts",
urls: "URLs",
repos: "Gitea repos",
workspaces: "Workspaces"
};
const descriptions: Record<AppView, string> = {
"add-issue": "Create a Gitea issue for the repo linked to the active tab.",
"add-url": "Link the active tab base URL to a workspace or Gitea repo.",
agents: "Agent files available for issue handoff.",
"open-issues": "Recent issues ordered by creation time.",
prompts: "Workspace-scoped prompt templates for issue content.",
urls: "Known browser targets and their workspace/repo links.",
repos: "Repositories ordered by latest default-branch commit.",
workspaces: `Authoritative workspaces from ${context?.workspaceRoot || "workspace_Father"}.`
};
viewHeader.hidden = state.activeView === "add-issue";
viewHeader.innerHTML =
state.activeView === "add-issue"
? ""
: `
<div>
<h2>${titles[state.activeView]}</h2>
<p>${escapeHtml(descriptions[state.activeView])}</p>
</div>
`;
if (state.activeView === "repos") {
viewContent.innerHTML = renderRepos(state.repos);
} else if (state.activeView === "workspaces") {
viewContent.innerHTML = renderWorkspaces(getAllWorkspaces(state));
} else if (state.activeView === "prompts") {
viewContent.innerHTML = renderPrompts(state.prompts);
} else if (state.activeView === "agents") {
viewContent.innerHTML = renderAgents(state.agents);
} else if (state.activeView === "open-issues") {
viewContent.innerHTML = renderOpenIssues();
} else if (state.activeView === "add-issue") {
viewContent.innerHTML = renderAddIssue();
} else if (state.activeView === "add-url") {
viewContent.innerHTML = renderAddUrl();
} else {
viewContent.innerHTML = renderUrls(allUrls());
}
viewContent.insertAdjacentHTML("beforeend", renderActiveDialog());
viewContent.insertAdjacentHTML("beforeend", renderScreenshotPreview());
hydrateIcons();
bindViewActions();
}
function renderChrome(): void {
const state = appStore.getState();
if (status) {
status.textContent = state.statusMessage;
status.dataset.tone = state.statusTone;
}
if (refreshData) {
refreshData.disabled = state.isLoadingData;
}
if (addIssueButton) {
addIssueButton.disabled = !canAddIssue();
}
if (manageViewSelect) {
manageViewSelect.value = ["agents", "prompts", "workspaces"].includes(state.activeView) ? state.activeView : "";
}
viewButtons.forEach((button) => {
const view = button.dataset.view;
button.dataset.active = view === state.activeView ? "true" : "false";
});
}
function shouldRenderView(): boolean {
const activeElement = document.activeElement;
if (!activeElement || !viewContent?.contains(activeElement)) {
return true;
}
return !activeElement.matches(
"#issue-subject, #issue-content, #issue-agent, #selected-issue-subject, #selected-issue-content, #issue-comment-content, #agent-name, #agent-file, #agent-description, #agent-content, #prompt-name, #prompt-content, #workspace-name, #workspace-search, [data-label-target]"
);
}
function syncFocusedFormControls(): void {
const state = appStore.getState();
const createIssue = viewContent?.querySelector<HTMLButtonElement>("#create-issue");
const saveSelectedIssue = viewContent?.querySelector<HTMLButtonElement>("#save-selected-issue");
const submitComment = viewContent?.querySelector<HTMLButtonElement>("#submit-issue-comment");
const saveWorkspace = viewContent?.querySelector<HTMLButtonElement>("#save-workspace");
const workspaceResults = viewContent?.querySelector<HTMLElement>("#workspace-folder-results");
const workspacePath = viewContent?.querySelector<HTMLInputElement>("#workspace-path");
const workspaceName = viewContent?.querySelector<HTMLInputElement>("#workspace-name");
if (createIssue) {
createIssue.disabled = !state.issueDraft.subject.trim() || state.isCreatingIssue;
}
if (submitComment) {
submitComment.disabled =
(!state.issueCommentDraft.content.trim() &&
!state.issueCommentDraft.screenshots.length &&
!state.issueCommentDraft.files.length) ||
state.isCreatingIssueComment;
}
if (saveWorkspace) {
saveWorkspace.disabled = !state.workspaceDraft.path.trim();
}
if (workspaceResults) {
workspaceResults.innerHTML = renderWorkspaceSearchResults(workspaceFolderCandidates(), state.workspaceDraft.search);
bindWorkspaceFolderResultActions();
}
if (workspacePath) {
workspacePath.value = state.workspaceDraft.path;
}
if (workspaceName && document.activeElement !== workspaceName) {
workspaceName.value = state.workspaceDraft.name;
}
if (saveSelectedIssue && state.selectedIssueDetail) {
const detailLabels = ISSUE_LABEL_OPTIONS.filter((label) =>
state.selectedIssueDetail?.issue.labels.some((issueLabel) => issueLabel.toLowerCase() === label.toLowerCase())
);
saveSelectedIssue.disabled =
!state.selectedIssueEditDraft.subject.trim() ||
state.isUpdatingIssue ||
(state.selectedIssueEditDraft.subject === state.selectedIssueDetail.issue.title &&
state.selectedIssueEditDraft.content === state.selectedIssueDetail.issue.body &&
state.selectedIssueEditDraft.labels.join("|") === detailLabels.join("|"));
}
}
function render(): void {
renderChrome();
if (shouldRenderView()) {
renderView();
} else {
syncFocusedFormControls();
}
ensureOpenIssuesLoaded();
ensureSelectedIssueDetailLoaded();
}
function ensureOpenIssuesLoaded(): void {
const state = appStore.getState();
const scopeKey = state.openIssuesLimitToCurrentRepo
? `current:${getActiveKnownUrl()?.giteaRepo?.fullName ?? "none"}:${state.openIssuesStatusFilters.join(",")}:${state.openIssuesLabelFilter || "all-labels"}`
: `all-repos:${state.openIssuesStatusFilters.join(",")}:${state.openIssuesLabelFilter || "all-labels"}`;
if (
state.activeView === "open-issues" &&
state.openIssuesScopeKey !== scopeKey &&
!state.isLoadingTargetIssues
) {
void appStore.getState().loadOpenIssues();
}
}
function ensureSelectedIssueDetailLoaded(): void {
const state = appStore.getState();
if (
state.activeView === "open-issues" &&
state.selectedIssueNumber &&
state.selectedIssueDetailNumber !== state.selectedIssueNumber &&
!state.isLoadingIssueDetail
) {
void appStore.getState().loadSelectedIssueDetail();
}
}
viewButtons.forEach((button) => {
button.addEventListener("click", () => {
const view = button.dataset.view as AppView | undefined;
if (view) {
appStore.getState().setActiveView(view);
}
});
});
manageViewSelect?.addEventListener("change", () => {
const view = manageViewSelect.value as AppView;
if (view === "agents" || view === "prompts" || view === "workspaces") {
appStore.getState().setActiveView(view);
}
});
refreshData?.addEventListener("click", () => {
void appStore.getState().loadData();
});
function setupActiveTabTracking(): void {
let refreshTimer: number | undefined;
const scheduleRefresh = (): void => {
if (refreshTimer !== undefined) {
window.clearTimeout(refreshTimer);
}
refreshTimer = window.setTimeout(() => {
refreshTimer = undefined;
void appStore.getState().refreshActiveTab();
}, 80);
};
globalThis.chrome?.tabs?.onActivated?.addListener(scheduleRefresh);
globalThis.chrome?.tabs?.onUpdated?.addListener((_tabId, changeInfo) => {
if (changeInfo.url || changeInfo.status === "complete") {
scheduleRefresh();
}
});
globalThis.chrome?.windows?.onFocusChanged?.addListener((windowId) => {
if (windowId !== chrome.windows.WINDOW_ID_NONE) {
scheduleRefresh();
}
});
document.addEventListener("visibilitychange", () => {
if (!document.hidden) {
scheduleRefresh();
}
});
window.addEventListener("focus", scheduleRefresh);
}
function setupLocalContextAutoRefresh(): void {
let isRefreshing = false;
window.setInterval(() => {
const state = appStore.getState();
const shouldRefresh =
!document.hidden &&
!state.isLoadingData &&
(state.activeView === "workspaces" ||
state.activeView === "prompts" ||
state.activeView === "urls" ||
state.activeDialog === "add-workspace" ||
state.activeDialog === "add-url");
if (!shouldRefresh || isRefreshing) {
return;
}
isRefreshing = true;
appStore.getState().refreshLocalContext().finally(() => {
isRefreshing = false;
});
}, 10000);
}
setupActiveTabTracking();
setupLocalContextAutoRefresh();
appStore.subscribe(render);
render();
void appStore.getState().loadData();