Add agents and screenshot crop workflow
Build / build (push) Successful in 12s

This commit is contained in:
2026-07-18 10:07:26 -05:00
parent f1f4c0df3a
commit 5221f2ebd2
9 changed files with 559 additions and 30 deletions
+4 -2
View File
@@ -44,10 +44,12 @@ Use `.env.local` for local secrets. Keep `.env.local` and any real token-bearing
- Runtime API calls are centralized in `src/api.ts`.
- Side panel state is centralized in the vanilla Zustand store at `src/store.ts`.
- The extension opens as a Chrome side panel. It is not a draggable in-page window.
- The side panel has icon menu sections for Add URL, URLs, Gitea repos, Workspaces, and Add Issue.
- The side panel has icon menu sections for Add URL, URLs, Gitea repos, Workspaces, Agents, and Add Issue.
- Add URL is enabled only when the active tab's base URL is not already in the URLs list. The user must pick either a workspace or a Gitea repo; the app infers the linked counterpart from local context.
- URLs can be deleted from the URLs list. User-added URLs are removed from storage; generated known URLs are hidden through the stored deleted-base list.
- Add Issue is enabled only when the active tab's base URL is already in the URLs list and has a linked Gitea repo. The issue form must show the target repo, capture visible-tab screenshots, accept Gitea-friendly file attachments, and include `Chrome profile: Silma` in created issue bodies.
- The Agents view lists available agent files. It currently contains one entry: `GLOBAL AGENT` backed by `AGENTS.md`.
- Add Issue is enabled only when the active tab's base URL is already in the URLs list and has a linked Gitea repo. The issue form must show the target repo, let the user select an agent file, capture visible-tab screenshots, accept multiple accumulated Gitea-friendly file attachments, and include `Chrome profile: Silma` plus the selected agent in created issue bodies.
- Clicking a captured screenshot must open an expanded preview with Close, Remove, and Crop controls.
- Newly created issues must be created with the `Change Request` label.
- If the Ready checkbox is checked, create the issue first, upload screenshots/files as issue assets second, and apply the `ready` label last.
- The Add Issue view also lists existing issues for the linked Gitea repo. Selecting an issue must show the original description, issue attachments, and comment chain, followed by a comment form that can add text, take additional screenshots, attach files, upload those files as issue assets, and submit a Gitea comment with attachment links.
+1 -1
View File
@@ -26,7 +26,7 @@ The deployable extension output is `dist/`. Load `dist/` in Chrome as an unpacke
Static assets, including the extension icon assets, live in `public/` and are copied into `dist/` during the build.
The app opens in the Chrome side panel. The top icon menu includes Add URL, URLs, Gitea repos, Workspaces, and Add Issue. Add URL is enabled only for an active tab base URL that is not already in the URLs list. URLs can be deleted from the list. Add Issue is enabled when the active tab base URL is already linked to a Gitea repo; it creates an issue with the `Change Request` label, uploads screenshots and files as issue assets, and can apply the `ready` label after uploads. The same view lists existing repo issues; selecting one shows the original description, attachments, comment chain, and a comment form with screenshots and file attachments. Rows can be marked Ready or deleted.
The app opens in the Chrome side panel. The top icon menu includes Add URL, URLs, Gitea repos, Workspaces, Agents, and Add Issue. Add URL is enabled only for an active tab base URL that is not already in the URLs list. URLs can be deleted from the list. The Agents view currently lists `GLOBAL AGENT` from `AGENTS.md`. Add Issue is enabled when the active tab base URL is already linked to a Gitea repo; it creates an issue with the selected agent file and `Change Request` label, uploads accumulated screenshots and files as issue assets, and can apply the `ready` label after uploads. Captured screenshots can be expanded, removed, or cropped. The same view lists existing repo issues; selecting one shows the original description, attachments, comment chain, and a comment form with screenshots and file attachments. Rows can be marked Ready or deleted.
Builds generate `public/local-context.json` from `/Users/Tabitha/.openclaw/workspace_Father`. That snapshot links known workspaces to local Gitea repos and is copied into `dist/` so the built extension is self-contained.
+4 -1
View File
@@ -39,10 +39,13 @@ Required variables:
- Add Issue is enabled only when the active tab's base URL is already in the URLs list and that URL is linked to a Gitea repo.
- The Add Issue view must show the Gitea repo that will receive the issue.
- The Add Issue form must let the user select the agent file for handoff. The initial agent is `GLOBAL AGENT` backed by `AGENTS.md`.
- The Add Issue view intentionally omits the redundant view header title/description and keeps the Ready checkbox label short.
- Screenshot capture uses Chrome's visible-tab capture API and requires the extension's `activeTab` permission plus `<all_urls>` host permission so capture still works after the side panel remains open across tab changes.
- Captured screenshots should appear immediately in Add Issue as thumbnail rows.
- Created issue bodies must include `Chrome profile: Silma`.
- Captured screenshots can be expanded, closed, removed, or cropped before upload.
- File attachments are accumulated across picker selections and all selected files must be uploaded to Gitea.
- Created issue bodies must include `Chrome profile: Silma` and the selected agent.
- Newly created issues must include the `Change Request` label.
- When Ready is checked, create the issue, upload screenshots/files as Gitea issue assets, then apply the `ready` label last.
- The Add Issue view lists existing issues for the linked Gitea repo. Selecting an issue shows the original description, issue attachments, comment chain, and a comment form for text, additional screenshots, and file attachments.
+4
View File
@@ -26,6 +26,10 @@
<i data-lucide="folder-git-2"></i>
<span>Workspaces</span>
</button>
<button class="tool-button" type="button" data-view="agents" title="Agents">
<i data-lucide="bot"></i>
<span>Agents</span>
</button>
<button id="add-issue" class="tool-button" type="button" data-view="add-issue" disabled title="Add issue">
<i data-lucide="circle-plus"></i>
<span>Add Issue</span>
+1 -1
View File
@@ -19,7 +19,7 @@ Important files:
Build with `npm run build`. The deployable output directory is `dist/`; load that folder in Chrome as an unpacked extension or point deployment tooling at it. Do not edit `dist/` directly.
UI behavior: the app renders as a Chrome side panel, not a draggable window. The menu items are Add URL, URLs, Gitea Repos, Workspaces, and Add Issue. Add URL is enabled only when the active tab base URL is not already known; the user must choose either a workspace or a Gitea repo, and the app infers the linked counterpart. URLs can be deleted from the list; user-added URLs are removed from storage, while generated known URLs are hidden through stored deleted bases. Add Issue is enabled only when the active tab base URL is known and linked to a Gitea repo. It shows the target repo, captures visible-tab screenshots with thumbnails, accepts file attachments, creates the Gitea issue with the `Change Request` label, uploads assets, then applies the `ready` label if requested. Created issue bodies include `Chrome profile: Silma`. The Add Issue view also lists existing issues for the linked repo; selecting an issue shows the original description, issue attachments, comment chain, and a comment form with text, screenshots, and file attachments. Existing issue rows can be marked Ready or deleted after confirmation. Gitea repos are ordered by latest default-branch commit. Workspace and URL views show established links between local workspaces, known URLs, and Gitea repos.
UI behavior: the app renders as a Chrome side panel, not a draggable window. The menu items are Add URL, URLs, Gitea Repos, Workspaces, Agents, and Add Issue. Add URL is enabled only when the active tab base URL is not already known; the user must choose either a workspace or a Gitea repo, and the app infers the linked counterpart. URLs can be deleted from the list; user-added URLs are removed from storage, while generated known URLs are hidden through stored deleted bases. The Agents view currently lists `GLOBAL AGENT` backed by `AGENTS.md`. Add Issue is enabled only when the active tab base URL is known and linked to a Gitea repo. It shows the target repo, captures visible-tab screenshots with thumbnails, accepts accumulated file attachments, lets the user select an agent file, creates the Gitea issue with the `Change Request` label, uploads all screenshots/files, then applies the `ready` label if requested. Captured screenshots can be expanded, removed, or cropped. Created issue bodies include `Chrome profile: Silma` and the selected agent. The Add Issue view also lists existing issues for the linked repo; selecting an issue shows the original description, issue attachments, comment chain, and a comment form with text, screenshots, and file attachments. Existing issue rows can be marked Ready or deleted after confirmation. Gitea repos are ordered by latest default-branch commit. Workspace and URL views show established links between local workspaces, known URLs, and Gitea repos.
Extension management rule: use the Codex Chrome extension with the Chrome profile named `Silma`. Do not use `Profile 1`.
+6 -1
View File
@@ -42,15 +42,20 @@
"name": "Workspaces",
"enabled": true
},
{
"name": "Agents",
"enabled": true
},
{
"name": "Add Issue",
"enabled": "when active tab base URL is known and linked to a Gitea repo"
}
],
"repo_ordering": "Gitea repos are ordered by latest default-branch commit timestamp.",
"agents_behavior": "The Agents view lists available agent files. The initial singular agent is GLOBAL AGENT backed by AGENTS.md.",
"add_url_behavior": "The user must pick either a workspace or a Gitea repo. The app infers the linked counterpart from local context and stores the URL link in chrome.storage.local.",
"delete_url_behavior": "URLs can be deleted from the list. User-added URLs are removed from chrome.storage.local; generated known URLs are hidden by storing their base URL in silmaAide.deletedUrlBases.",
"add_issue_behavior": "The form shows the target repo for the active known URL, captures visible-tab screenshots with thumbnails, accepts file attachments, creates a Gitea issue with the Change Request label, uploads screenshots/files as issue assets, then applies the ready label after uploads when requested. Issue bodies include Chrome profile: Silma. The view intentionally omits the redundant Add Issue header copy and keeps the Ready checkbox label short.",
"add_issue_behavior": "The form shows the target repo for the active known URL, lets the user select an agent file, captures visible-tab screenshots with thumbnails, accepts accumulated file attachments, creates a Gitea issue with the Change Request label, uploads all screenshots/files as issue assets, then applies the ready label after uploads when requested. Captured screenshots can be expanded, removed, or cropped. Issue bodies include Chrome profile: Silma and the selected agent. The view intentionally omits the redundant Add Issue header copy and keeps the Ready checkbox label short.",
"issue_comment_behavior": "The Add Issue view lists existing issues for the active URL's linked Gitea repo. Selecting an issue shows the original description, issue attachments, comment chain, and a comment form that accepts text, screenshots, and file attachments, uploads attachments as issue assets, and submits a Gitea issue comment with attachment links. Issue rows can apply the ready label or delete the issue after confirmation.",
"known_urls": [
{
+187 -18
View File
@@ -1,9 +1,10 @@
import { createIcons, CirclePlus, FolderGit2, GitBranch, Link, List, RefreshCw } from "lucide";
import { createIcons, Bot, CirclePlus, FolderGit2, GitBranch, Link, List, RefreshCw } from "lucide";
import "./styles.css";
import {
appStore,
CHROME_PROFILE_NAME,
findKnownUrlForBase,
type AgentProfile,
type AddUrlMode,
type AppView,
type ScreenshotAttachment
@@ -21,6 +22,7 @@ const viewButtons = Array.from(document.querySelectorAll<HTMLButtonElement>("[da
createIcons({
icons: {
Bot,
CirclePlus,
FolderGit2,
GitBranch,
@@ -182,6 +184,26 @@ function renderWorkspaces(workspaces: LocalWorkspace[]): string {
`;
}
function renderAgents(agents: AgentProfile[]): string {
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>
</article>
`
)
.join("")}
</div>
`;
}
function renderUrls(urls: KnownUrl[]): string {
if (!urls.length) {
return `<div class="empty-state">No known URLs registered.</div>`;
@@ -298,18 +320,39 @@ function renderAddUrl(): string {
`;
}
function renderAttachmentList(items: string[], emptyText: string): string {
if (!items.length) {
return `<span class="muted">${escapeHtml(emptyText)}</span>`;
function renderFileList(files: File[], target: "issue" | "comment"): string {
if (!files.length) {
return `<span class="muted">No files selected.</span>`;
}
const attribute = target === "issue" ? "data-delete-issue-file" : "data-delete-comment-file";
return `
<ul class="attachment-list">
${items.map((item) => `<li>${escapeHtml(item)}</li>`).join("")}
</ul>
<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 renderBodyText(body: string, emptyText: string): string {
const trimmed = body.trim();
if (!trimmed) {
@@ -370,6 +413,7 @@ function renderScreenshotList(screenshots: ScreenshotAttachment[], deleteAction:
}
const attribute = deleteAction === "issue" ? "data-delete-screenshot" : "data-delete-comment-screenshot";
const previewTarget = deleteAction === "issue" ? "issue" : "comment";
return `
<div class="screenshot-list">
@@ -377,7 +421,9 @@ function renderScreenshotList(screenshots: ScreenshotAttachment[], deleteAction:
.map(
(screenshot) => `
<figure class="screenshot-item">
<img src="${screenshot.dataUrl}" alt="${escapeHtml(screenshot.name)}" />
<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>
@@ -388,6 +434,62 @@ function renderScreenshotList(screenshots: ScreenshotAttachment[], deleteAction:
`;
}
function getPreviewScreenshot(): ScreenshotAttachment | null {
const state = appStore.getState();
const preview = state.screenshotPreview;
if (!preview) {
return null;
}
const screenshots = preview.target === "issue" ? state.issueDraft.screenshots : state.issueCommentDraft.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)}" />
<div class="crop-box" style="left:${crop.left}%; top:${crop.top}%; width:${crop.width}%; height:${crop.height}%"></div>
</div>
<div class="crop-controls">
${(["left", "top", "width", "height"] as const)
.map(
(field) => `
<label>
<span>${field}</span>
<input type="range" min="${field === "width" || field === "height" ? "1" : "0"}" max="100" value="${crop[field]}" data-crop-field="${field}" />
<output>${crop[field]}%</output>
</label>
`
)
.join("")}
</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: GiteaIssueSummary[], selectedIssueNumber: number | null): string {
const state = appStore.getState();
@@ -450,10 +552,7 @@ function renderIssueCommentForm(issue: GiteaIssueSummary): string {
<span>File attachments</span>
<input id="issue-comment-files" type="file" multiple />
</label>
${renderAttachmentList(
draft.files.map((file) => file.name),
"No files selected."
)}
${renderFileList(draft.files, "comment")}
<button id="submit-issue-comment" class="primary-button" type="button" ${canSubmit ? "" : "disabled"}>
${state.isCreatingIssueComment ? "Commenting..." : "Comment"}
@@ -550,6 +649,13 @@ function renderAddIssue(): string {
<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>
<section class="attachment-section">
<div class="attachment-heading">
<span>Screenshots</span>
@@ -564,10 +670,7 @@ function renderAddIssue(): string {
<span>File attachments</span>
<input id="issue-files" type="file" multiple />
</label>
${renderAttachmentList(
draft.files.map((file) => file.name),
"No files selected."
)}
${renderFileList(draft.files, "issue")}
<label class="checkbox-row">
<input id="issue-ready" type="checkbox" ${draft.ready ? "checked" : ""} />
@@ -642,6 +745,12 @@ function bindViewActions(): void {
});
});
viewContent?.querySelector<HTMLSelectElement>("#issue-agent")?.addEventListener("change", (event) => {
appStore.getState().setIssueDraft({
agentId: (event.target as HTMLSelectElement).value
});
});
viewContent?.querySelector<HTMLInputElement>("#issue-ready")?.addEventListener("change", (event) => {
appStore.getState().setIssueDraft({
ready: (event.target as HTMLInputElement).checked
@@ -649,7 +758,9 @@ function bindViewActions(): void {
});
viewContent?.querySelector<HTMLInputElement>("#issue-files")?.addEventListener("change", (event) => {
appStore.getState().setIssueFiles(Array.from((event.target as HTMLInputElement).files ?? []));
const input = event.target as HTMLInputElement;
appStore.getState().setIssueFiles(Array.from(input.files ?? []));
input.value = "";
});
viewContent?.querySelector<HTMLButtonElement>("#capture-screenshot")?.addEventListener("click", () => {
@@ -665,6 +776,25 @@ function bindViewActions(): void {
});
});
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")) {
appStore.getState().openScreenshotPreview(target, id);
}
});
});
viewContent?.querySelectorAll<HTMLButtonElement>("[data-select-issue]").forEach((button) => {
button.addEventListener("click", () => {
const issueNumber = Number(button.dataset.selectIssue);
@@ -707,7 +837,9 @@ function bindViewActions(): void {
});
viewContent?.querySelector<HTMLInputElement>("#issue-comment-files")?.addEventListener("change", (event) => {
appStore.getState().setIssueCommentFiles(Array.from((event.target as HTMLInputElement).files ?? []));
const input = event.target as HTMLInputElement;
appStore.getState().setIssueCommentFiles(Array.from(input.files ?? []));
input.value = "";
});
viewContent?.querySelector<HTMLButtonElement>("#capture-comment-screenshot")?.addEventListener("click", () => {
@@ -723,6 +855,38 @@ function bindViewActions(): void {
});
});
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);
}
});
});
viewContent?.querySelectorAll<HTMLInputElement>("[data-crop-field]").forEach((input) => {
input.addEventListener("input", () => {
const field = input.dataset.cropField;
if (field === "left" || field === "top" || field === "width" || field === "height") {
appStore.getState().setScreenshotPreviewCrop({
[field]: Number(input.value)
});
}
});
});
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();
});
@@ -742,6 +906,7 @@ function renderView(): void {
const titles: Record<AppView, string> = {
"add-issue": "Add issue",
"add-url": "Add URL",
agents: "Agents",
urls: "URLs",
repos: "Gitea repos",
workspaces: "Workspaces"
@@ -750,6 +915,7 @@ function renderView(): void {
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.",
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"}.`
@@ -770,6 +936,8 @@ function renderView(): void {
viewContent.innerHTML = renderRepos(state.repos);
} else if (state.activeView === "workspaces") {
viewContent.innerHTML = renderWorkspaces(context?.workspaces ?? []);
} else if (state.activeView === "agents") {
viewContent.innerHTML = renderAgents(state.agents);
} else if (state.activeView === "add-issue") {
viewContent.innerHTML = renderAddIssue();
} else if (state.activeView === "add-url") {
@@ -778,6 +946,7 @@ function renderView(): void {
viewContent.innerHTML = renderUrls(allUrls());
}
viewContent.insertAdjacentHTML("beforeend", renderScreenshotPreview());
bindViewActions();
}
+222 -3
View File
@@ -21,11 +21,28 @@ import { loadLocalContext, type LocalContext } from "./localContext";
import type { KnownUrl, LocalGiteaRepoLink, LocalWorkspace } from "./localContext";
export type StatusTone = "neutral" | "success" | "error";
export type AppView = "urls" | "repos" | "workspaces" | "add-url" | "add-issue";
export type AppView = "urls" | "repos" | "workspaces" | "agents" | "add-url" | "add-issue";
export type AddUrlMode = "workspace" | "repo";
export type ScreenshotTarget = "issue" | "comment";
export const CHROME_PROFILE_NAME = "Silma";
export interface AgentProfile {
id: string;
name: string;
file: string;
description: string;
}
export const AVAILABLE_AGENTS: AgentProfile[] = [
{
id: "global-agent",
name: "GLOBAL AGENT",
file: "AGENTS.md",
description: "Default project-wide agent rules for issue handoff."
}
];
interface AddUrlDraft {
mode: AddUrlMode | null;
selectedValue: string;
@@ -41,6 +58,7 @@ export interface ScreenshotAttachment {
interface IssueDraft {
subject: string;
content: string;
agentId: string;
ready: boolean;
screenshots: ScreenshotAttachment[];
files: File[];
@@ -52,6 +70,19 @@ interface IssueCommentDraft {
files: File[];
}
interface CropArea {
left: number;
top: number;
width: number;
height: number;
}
interface ScreenshotPreview {
target: ScreenshotTarget;
id: string;
crop: CropArea;
}
interface AppState {
activeView: AppView;
config: typeof giteaConfig;
@@ -65,6 +96,8 @@ interface AppState {
addUrlDraft: AddUrlDraft;
issueDraft: IssueDraft;
issueCommentDraft: IssueCommentDraft;
agents: AgentProfile[];
screenshotPreview: ScreenshotPreview | null;
targetIssues: GiteaIssueSummary[];
issueListRepoFullName: string;
selectedIssueNumber: number | null;
@@ -86,10 +119,17 @@ interface AppState {
setAddUrlDraft: (draft: Partial<AddUrlDraft>) => void;
setIssueDraft: (draft: Partial<Omit<IssueDraft, "screenshots" | "files">>) => void;
setIssueFiles: (files: File[]) => void;
deleteIssueFile: (index: number) => void;
deleteIssueScreenshot: (id: string) => void;
setIssueCommentDraft: (draft: Partial<Omit<IssueCommentDraft, "screenshots" | "files">>) => void;
setIssueCommentFiles: (files: File[]) => void;
deleteIssueCommentFile: (index: number) => void;
deleteIssueCommentScreenshot: (id: string) => void;
openScreenshotPreview: (target: ScreenshotTarget, id: string) => void;
closeScreenshotPreview: () => void;
setScreenshotPreviewCrop: (crop: Partial<CropArea>) => void;
applyScreenshotCrop: () => Promise<void>;
removePreviewScreenshot: () => void;
selectIssue: (issueNumber: number) => void;
loadTargetIssues: (force?: boolean) => Promise<void>;
loadSelectedIssueDetail: (force?: boolean) => Promise<void>;
@@ -361,7 +401,84 @@ function createScreenshotAttachment(dataUrl: string, blob: Blob): ScreenshotAtta
};
}
function getAgentById(id: string): AgentProfile {
return AVAILABLE_AGENTS.find((agent) => agent.id === id) ?? AVAILABLE_AGENTS[0];
}
function appendFiles(existingFiles: File[], incomingFiles: File[]): File[] {
const fileKeys = new Set(existingFiles.map((file) => `${file.name}:${file.size}:${file.lastModified}`));
const nextFiles = [...existingFiles];
for (const file of incomingFiles) {
const key = `${file.name}:${file.size}:${file.lastModified}`;
if (!fileKeys.has(key)) {
fileKeys.add(key);
nextFiles.push(file);
}
}
return nextFiles;
}
function clampCrop(crop: CropArea): CropArea {
const left = Math.min(Math.max(crop.left, 0), 99);
const top = Math.min(Math.max(crop.top, 0), 99);
const width = Math.min(Math.max(crop.width, 1), 100 - left);
const height = Math.min(Math.max(crop.height, 1), 100 - top);
return { left, top, width, height };
}
function loadImage(dataUrl: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error("Could not load screenshot for cropping."));
image.src = dataUrl;
});
}
function canvasToBlob(canvas: HTMLCanvasElement): Promise<Blob> {
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) {
resolve(blob);
return;
}
reject(new Error("Could not crop the screenshot."));
}, "image/png");
});
}
async function cropScreenshot(screenshot: ScreenshotAttachment, crop: CropArea): Promise<ScreenshotAttachment> {
const image = await loadImage(screenshot.dataUrl);
const safeCrop = clampCrop(crop);
const sourceX = Math.floor((safeCrop.left / 100) * image.naturalWidth);
const sourceY = Math.floor((safeCrop.top / 100) * image.naturalHeight);
const sourceWidth = Math.max(1, Math.floor((safeCrop.width / 100) * image.naturalWidth));
const sourceHeight = Math.max(1, Math.floor((safeCrop.height / 100) * image.naturalHeight));
const canvas = document.createElement("canvas");
canvas.width = sourceWidth;
canvas.height = sourceHeight;
const context = canvas.getContext("2d");
if (!context) {
throw new Error("Could not prepare the screenshot cropper.");
}
context.drawImage(image, sourceX, sourceY, sourceWidth, sourceHeight, 0, 0, sourceWidth, sourceHeight);
const blob = await canvasToBlob(canvas);
return {
...screenshot,
dataUrl: canvas.toDataURL("image/png"),
blob
};
}
function buildIssueBody(state: AppState, knownUrl: KnownUrl): string {
const agent = getAgentById(state.issueDraft.agentId);
const parts = [
state.issueDraft.content.trim(),
"---",
@@ -369,6 +486,7 @@ function buildIssueBody(state: AppState, knownUrl: KnownUrl): string {
`Base URL: ${state.activeBaseUrl || getBaseUrl(knownUrl.url)}`,
`Workspace: ${knownUrl.workspaceName} (${knownUrl.workspacePath})`,
`Gitea repo: ${knownUrl.giteaRepo?.fullName ?? "Not linked"}`,
`Assigned agent: ${agent.name} (${agent.file})`,
`Chrome profile: ${CHROME_PROFILE_NAME}`
];
@@ -413,6 +531,7 @@ export const appStore = createStore<AppState>()((set, get) => ({
issueDraft: {
subject: "",
content: "",
agentId: AVAILABLE_AGENTS[0].id,
ready: false,
screenshots: [],
files: []
@@ -422,6 +541,8 @@ export const appStore = createStore<AppState>()((set, get) => ({
screenshots: [],
files: []
},
agents: AVAILABLE_AGENTS,
screenshotPreview: null,
targetIssues: [],
issueListRepoFullName: "",
selectedIssueNumber: null,
@@ -464,7 +585,14 @@ export const appStore = createStore<AppState>()((set, get) => ({
set((state) => ({
issueDraft: {
...state.issueDraft,
files
files: appendFiles(state.issueDraft.files, files)
}
})),
deleteIssueFile: (index) =>
set((state) => ({
issueDraft: {
...state.issueDraft,
files: state.issueDraft.files.filter((_file, fileIndex) => fileIndex !== index)
}
})),
deleteIssueScreenshot: (id) =>
@@ -485,7 +613,14 @@ export const appStore = createStore<AppState>()((set, get) => ({
set((state) => ({
issueCommentDraft: {
...state.issueCommentDraft,
files
files: appendFiles(state.issueCommentDraft.files, files)
}
})),
deleteIssueCommentFile: (index) =>
set((state) => ({
issueCommentDraft: {
...state.issueCommentDraft,
files: state.issueCommentDraft.files.filter((_file, fileIndex) => fileIndex !== index)
}
})),
deleteIssueCommentScreenshot: (id) =>
@@ -495,6 +630,89 @@ export const appStore = createStore<AppState>()((set, get) => ({
screenshots: state.issueCommentDraft.screenshots.filter((screenshot) => screenshot.id !== id)
}
})),
openScreenshotPreview: (target, id) =>
set({
screenshotPreview: {
target,
id,
crop: {
left: 0,
top: 0,
width: 100,
height: 100
}
}
}),
closeScreenshotPreview: () => set({ screenshotPreview: null }),
setScreenshotPreviewCrop: (crop) =>
set((state) => ({
screenshotPreview: state.screenshotPreview
? {
...state.screenshotPreview,
crop: clampCrop({
...state.screenshotPreview.crop,
...crop
})
}
: null
})),
applyScreenshotCrop: async () => {
const state = appStore.getState();
const preview = state.screenshotPreview;
if (!preview) {
return;
}
const screenshots = preview.target === "issue" ? state.issueDraft.screenshots : state.issueCommentDraft.screenshots;
const screenshot = screenshots.find((item) => item.id === preview.id);
if (!screenshot) {
setStatus(set, "Could not find that screenshot.", "error");
set({ screenshotPreview: null });
return;
}
try {
const cropped = await cropScreenshot(screenshot, preview.crop);
set((current) =>
preview.target === "issue"
? {
issueDraft: {
...current.issueDraft,
screenshots: current.issueDraft.screenshots.map((item) => (item.id === preview.id ? cropped : item))
},
screenshotPreview: null
}
: {
issueCommentDraft: {
...current.issueCommentDraft,
screenshots: current.issueCommentDraft.screenshots.map((item) => (item.id === preview.id ? cropped : item))
},
screenshotPreview: null
}
);
setStatus(set, `Cropped ${screenshot.name}.`, "success");
} catch (error) {
const message = error instanceof Error ? error.message : "Could not crop the screenshot.";
setStatus(set, message, "error");
}
},
removePreviewScreenshot: () => {
const preview = appStore.getState().screenshotPreview;
if (!preview) {
return;
}
if (preview.target === "issue") {
appStore.getState().deleteIssueScreenshot(preview.id);
} else {
appStore.getState().deleteIssueCommentScreenshot(preview.id);
}
set({ screenshotPreview: null });
},
selectIssue: (issueNumber) =>
set((state) => ({
selectedIssueNumber: issueNumber,
@@ -814,6 +1032,7 @@ export const appStore = createStore<AppState>()((set, get) => ({
issueDraft: {
subject: "",
content: "",
agentId: AVAILABLE_AGENTS[0].id,
ready: false,
screenshots: [],
files: []
+130 -3
View File
@@ -62,7 +62,7 @@ h1 {
.toolbar {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 1px;
border-bottom: 1px solid #d9e2ec;
background: #d9e2ec;
@@ -179,7 +179,8 @@ h1 {
}
.workspace-row,
.url-row {
.url-row,
.agent-row {
display: grid;
align-items: center;
gap: 5px;
@@ -197,7 +198,8 @@ h1 {
}
.workspace-row:first-child,
.url-row:first-child {
.url-row:first-child,
.agent-row:first-child {
border-top: 1px solid #d9e2ec;
}
@@ -645,6 +647,37 @@ input[readonly] {
line-height: 1.35;
}
.file-list {
display: grid;
gap: 3px;
}
.file-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
gap: 6px;
border: 1px solid #d9e2ec;
border-radius: 6px;
padding: 4px;
background: #ffffff;
color: #334e68;
font-size: 10px;
}
.file-row span:first-child {
min-width: 0;
overflow: hidden;
font-weight: 750;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-row span:nth-child(2) {
color: #829ab1;
font-weight: 750;
}
.screenshot-list {
display: grid;
gap: 5px;
@@ -671,6 +704,13 @@ input[readonly] {
object-fit: cover;
}
.screenshot-preview-button {
border: 0;
padding: 0;
background: transparent;
cursor: zoom-in;
}
.screenshot-item figcaption {
min-width: 0;
overflow: hidden;
@@ -681,6 +721,93 @@ input[readonly] {
white-space: nowrap;
}
.screenshot-modal {
position: fixed;
inset: 0;
z-index: 10;
display: grid;
place-items: center;
padding: 12px;
background: rgba(15, 23, 42, 0.42);
}
.screenshot-modal-panel {
display: grid;
gap: 8px;
width: min(100%, 520px);
max-height: calc(100vh - 24px);
overflow: auto;
border: 1px solid #9fb3c8;
border-radius: 8px;
padding: 8px;
background: #ffffff;
}
.screenshot-modal-toolbar,
.modal-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.screenshot-modal-toolbar span {
min-width: 0;
overflow: hidden;
color: #102a43;
font-size: 11px;
font-weight: 850;
text-overflow: ellipsis;
white-space: nowrap;
}
.crop-frame {
position: relative;
overflow: hidden;
border-radius: 6px;
background: #102a43;
}
.crop-frame img {
display: block;
width: 100%;
max-height: 54vh;
object-fit: contain;
}
.crop-box {
position: absolute;
border: 2px solid #f59e0b;
background: rgba(245, 158, 11, 0.12);
pointer-events: none;
}
.crop-controls {
display: grid;
gap: 5px;
}
.crop-controls label {
display: grid;
grid-template-columns: 44px minmax(0, 1fr) 42px;
align-items: center;
gap: 6px;
color: #52616f;
font-size: 10px;
font-weight: 850;
text-transform: uppercase;
}
.crop-controls input {
width: 100%;
}
.crop-controls output {
color: #334e68;
font-variant-numeric: tabular-nums;
text-align: right;
}
.form-stack .checkbox-row {
display: flex;
align-items: center;