Add editable agents and drag cropper
Build / build (push) Successful in 11s

This commit is contained in:
2026-07-18 10:17:06 -05:00
parent 5221f2ebd2
commit fb2c9e5d03
8 changed files with 457 additions and 66 deletions
+6 -4
View File
@@ -47,11 +47,13 @@ Use `.env.local` for local secrets. Keep `.env.local` and any real token-bearing
- 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.
- 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.
- The Agents view lists available agent files and must allow adding, editing, and deleting persisted agents from `chrome.storage.local`. At least one agent must remain available.
- The default `GLOBAL AGENT` is backed by `AGENTS.md` and contains agnostic handoff rules with template variables such as `{{git}}`, `{{workspace}}`, `{{url}}`, `{{baseUrl}}`, `{{profile}}`, and `{{issue}}`.
- 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, the active URL the extension believes it is attached to, 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. The crop area should be a simple draggable/resizable box without a shaded crop fill.
- 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.
- When creating an issue, upload the selected rendered agent file as an issue asset along with screenshots and files.
- If the Ready checkbox is checked, create the issue first, upload the rendered agent file plus 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.
- Existing issue rows must allow applying the `ready` label and deleting the issue. Deletion must require user confirmation.
- Keep the Add Issue view dense; do not show the redundant view header title/description inside this view, and keep the Ready checkbox label short.
+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, 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.
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 manages persisted agent files, starting with `GLOBAL AGENT` from `AGENTS.md`; agents can use variables such as `{{git}}`, `{{workspace}}`, and `{{url}}` in their markdown content. Add Issue is enabled when the active tab base URL is already linked to a Gitea repo; it shows the active URL being attached, creates an issue with the selected agent and `Change Request` label, uploads the rendered agent file plus accumulated screenshots and files as issue assets, and can apply the `ready` label after uploads. Captured screenshots can be expanded, removed, or cropped with a plain draggable/resizable crop box. 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.
+12 -4
View File
@@ -38,16 +38,17 @@ Required variables:
## Issue Creation
- 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 must show the Gitea repo that will receive the issue and the active URL the extension believes it is attached to.
- The Add Issue form must let the user select the agent file for handoff. Agents are managed in the Agents view, persisted in `chrome.storage.local`, and 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.
- Captured screenshots can be expanded, closed, removed, or cropped before upload.
- Captured screenshots can be expanded, closed, removed, or cropped before upload. The crop UI should use a plain draggable/resizable crop box instead of shaded slider-based controls.
- 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.
- Created issues must upload the selected rendered agent file as a Gitea issue asset. Supported template variables in agent content are `{{git}}`, `{{repo}}`, `{{workspace}}`, `{{url}}`, `{{baseUrl}}`, `{{profile}}`, and `{{issue}}`.
- 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.
- When Ready is checked, create the issue, upload the rendered agent file plus 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.
- Issue comments should upload screenshots/files as issue assets first, then create a comment containing links to those attachments.
- Existing issue rows can apply the `ready` label or delete the issue. Deletion must require confirmation.
@@ -58,6 +59,13 @@ Required variables:
- Deleting a user-added URL removes it from `silmaAide.userUrls` in `chrome.storage.local`.
- Deleting a generated known URL stores its base URL in `silmaAide.deletedUrlBases` so it is hidden without editing generated local context.
## Agent Management
- The Agents view must allow adding, editing, and deleting agent files.
- At least one agent must remain available so Add Issue always has a handoff file.
- Agent records are persisted in `silmaAide.agents`.
- The default `GLOBAL AGENT` should remain agnostic and use template variables instead of hard-coded project paths where possible.
## Agent Checklist
1. Run `npm run build`.
+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, 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.
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 manages persisted agent files and starts with `GLOBAL AGENT` backed by `AGENTS.md`; agent markdown can use `{{git}}`, `{{repo}}`, `{{workspace}}`, `{{url}}`, `{{baseUrl}}`, `{{profile}}`, and `{{issue}}` template variables. Add Issue is enabled only when the active tab base URL is known and linked to a Gitea repo. It shows the target repo and active URL, 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 the rendered agent file plus all screenshots/files, then applies the `ready` label if requested. Captured screenshots can be expanded, removed, or cropped with a plain draggable/resizable crop box. 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`.
+2 -2
View File
@@ -52,10 +52,10 @@
}
],
"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.",
"agents_behavior": "The Agents view manages persisted agent files. The initial agent is GLOBAL AGENT backed by AGENTS.md, and agent markdown supports {{git}}, {{repo}}, {{workspace}}, {{url}}, {{baseUrl}}, {{profile}}, and {{issue}} template variables.",
"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, 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.",
"add_issue_behavior": "The form shows the target repo and active URL 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 the rendered agent file plus all screenshots/files as issue assets, then applies the ready label after uploads when requested. Captured screenshots can be expanded, removed, or cropped with a plain draggable/resizable crop box. 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": [
{
+166 -26
View File
@@ -12,6 +12,8 @@ import {
import type { GiteaIssueAttachment, GiteaIssueComment, GiteaIssueSummary, GiteaRepoSummary } from "./api";
import type { KnownUrl, LocalContext, 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 addUrlButton = document.querySelector<HTMLButtonElement>("#add-url");
@@ -185,6 +187,9 @@ function renderWorkspaces(workspaces: LocalWorkspace[]): string {
}
function renderAgents(agents: AgentProfile[]): string {
const state = appStore.getState();
const draft = state.agentDraft;
return `
<div class="compact-list">
${agents
@@ -196,11 +201,39 @@ function renderAgents(agents: AgentProfile[]): string {
<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>
`;
}
@@ -466,20 +499,11 @@ function renderScreenshotPreview(): string {
</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("")}
<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>
@@ -631,10 +655,13 @@ function renderAddIssue(): string {
return `
<article class="item-card issue-card">
<div class="target-summary">
<span>Target repo</span>
<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="path-text">${escapeHtml(knownUrl.workspacePath)}</span>
<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>
@@ -696,6 +723,56 @@ function openUrl(url: string): void {
}
function bindViewActions(): void {
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?.querySelectorAll<HTMLButtonElement>("[data-open-url]").forEach((button) => {
button.addEventListener("click", () => {
const url = button.dataset.openUrl;
@@ -864,16 +941,7 @@ function bindViewActions(): void {
});
});
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)
});
}
});
});
bindCropInteractions();
viewContent?.querySelector<HTMLButtonElement>("[data-close-screenshot-preview]")?.addEventListener("click", () => {
appStore.getState().closeScreenshotPreview();
@@ -896,6 +964,78 @@ function bindViewActions(): void {
});
}
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;
+190 -6
View File
@@ -32,17 +32,53 @@ export interface AgentProfile {
name: string;
file: string;
description: string;
content: string;
}
const DEFAULT_GLOBAL_AGENT_CONTENT = `# GLOBAL AGENT
## Mission
Use this file as the governing handoff contract for the issue it is attached to. It is intentionally project-agnostic and should be interpreted in the context supplied by the issue, linked workspace, linked Git repository, and source URL.
## Context Variables
- Git target: {{git}}
- Workspace: {{workspace}}
- Source URL: {{url}}
- Base URL: {{baseUrl}}
- Chrome profile: {{profile}}
- Issue: {{issue}}
## Operating Rules
- Treat the issue subject, issue body, screenshots, and attachments as the source of truth for the requested change.
- Inspect {{workspace}} before proposing or applying code changes.
- Use {{git}} as the repository identity for branch, commit, issue, and review references.
- Preserve user intent. If evidence conflicts, state the conflict clearly and ask only when the next action would otherwise be risky.
- Keep changes scoped to the issue. Avoid unrelated refactors, formatting churn, and generated output unless the issue requires them.
- Run the narrowest meaningful verification for the changed surface, then report exactly what passed or could not be run.
- Do not include credentials, local-only secrets, or private tokens in comments, commits, logs, or attachments.
- When handing off to another agent, include current status, touched files, verification results, and any unresolved decisions.`;
export const AVAILABLE_AGENTS: AgentProfile[] = [
{
id: "global-agent",
name: "GLOBAL AGENT",
file: "AGENTS.md",
description: "Default project-wide agent rules for issue handoff."
description: "Default project-wide agent rules for issue handoff.",
content: DEFAULT_GLOBAL_AGENT_CONTENT
}
];
const EMPTY_AGENT_DRAFT: AgentProfile = {
id: "",
name: "",
file: "AGENTS.md",
description: "",
content: ""
};
interface AddUrlDraft {
mode: AddUrlMode | null;
selectedValue: string;
@@ -97,6 +133,8 @@ interface AppState {
issueDraft: IssueDraft;
issueCommentDraft: IssueCommentDraft;
agents: AgentProfile[];
agentDraft: AgentProfile;
editingAgentId: string | null;
screenshotPreview: ScreenshotPreview | null;
targetIssues: GiteaIssueSummary[];
issueListRepoFullName: string;
@@ -125,6 +163,11 @@ interface AppState {
setIssueCommentFiles: (files: File[]) => void;
deleteIssueCommentFile: (index: number) => void;
deleteIssueCommentScreenshot: (id: string) => void;
setAgentDraft: (draft: Partial<AgentProfile>) => void;
startNewAgent: () => void;
editAgent: (id: string) => void;
saveAgent: () => Promise<void>;
deleteAgent: (id: string) => Promise<void>;
openScreenshotPreview: (target: ScreenshotTarget, id: string) => void;
closeScreenshotPreview: () => void;
setScreenshotPreviewCrop: (crop: Partial<CropArea>) => void;
@@ -148,6 +191,7 @@ interface AppState {
const USER_URLS_STORAGE_KEY = "silmaAide.userUrls";
const DELETED_URL_BASES_STORAGE_KEY = "silmaAide.deletedUrlBases";
const AGENTS_STORAGE_KEY = "silmaAide.agents";
let statusTimer: number | undefined;
function getBaseUrl(value: string): string {
@@ -401,8 +445,23 @@ function createScreenshotAttachment(dataUrl: string, blob: Blob): ScreenshotAtta
};
}
function getAgentById(id: string): AgentProfile {
return AVAILABLE_AGENTS.find((agent) => agent.id === id) ?? AVAILABLE_AGENTS[0];
function normalizeAgent(agent: Partial<AgentProfile>, index: number): AgentProfile {
return {
id: agent.id || crypto.randomUUID(),
name: agent.name?.trim() || (index === 0 ? "GLOBAL AGENT" : "Unnamed agent"),
file: agent.file?.trim() || "AGENTS.md",
description: agent.description?.trim() || "",
content: agent.content?.trim() || DEFAULT_GLOBAL_AGENT_CONTENT
};
}
function normalizeAgents(agents: AgentProfile[]): AgentProfile[] {
const normalized = agents.map(normalizeAgent);
return normalized.length ? normalized : AVAILABLE_AGENTS;
}
function getAgentById(id: string, agents: AgentProfile[]): AgentProfile {
return agents.find((agent) => agent.id === id) ?? agents[0] ?? AVAILABLE_AGENTS[0];
}
function appendFiles(existingFiles: File[], incomingFiles: File[]): File[] {
@@ -478,7 +537,7 @@ async function cropScreenshot(screenshot: ScreenshotAttachment, crop: CropArea):
}
function buildIssueBody(state: AppState, knownUrl: KnownUrl): string {
const agent = getAgentById(state.issueDraft.agentId);
const agent = getAgentById(state.issueDraft.agentId, state.agents);
const parts = [
state.issueDraft.content.trim(),
"---",
@@ -493,6 +552,24 @@ function buildIssueBody(state: AppState, knownUrl: KnownUrl): string {
return parts.filter(Boolean).join("\n\n");
}
function renderAgentContent(agent: AgentProfile, state: AppState, knownUrl: KnownUrl, issueNumber: number): string {
const replacements: Record<string, string> = {
git: knownUrl.giteaRepo?.fullName ?? "",
repo: knownUrl.giteaRepo?.fullName ?? "",
workspace: knownUrl.workspacePath,
url: state.activeTabUrl || knownUrl.url,
baseUrl: state.activeBaseUrl || getBaseUrl(knownUrl.url),
profile: CHROME_PROFILE_NAME,
issue: `#${issueNumber}`
};
return agent.content.replace(/\{\{(git|repo|workspace|url|baseUrl|profile|issue)\}\}/g, (_match, key: string) => replacements[key] ?? "");
}
function agentContentBlob(content: string): Blob {
return new Blob([content], { type: "text/markdown;charset=utf-8" });
}
function buildAttachmentMarkdown(attachments: GiteaIssueAttachment[]): string {
const lines = attachments
.filter((attachment) => attachment.downloadUrl)
@@ -542,6 +619,8 @@ export const appStore = createStore<AppState>()((set, get) => ({
files: []
},
agents: AVAILABLE_AGENTS,
agentDraft: AVAILABLE_AGENTS[0],
editingAgentId: AVAILABLE_AGENTS[0].id,
screenshotPreview: null,
targetIssues: [],
issueListRepoFullName: "",
@@ -630,6 +709,94 @@ export const appStore = createStore<AppState>()((set, get) => ({
screenshots: state.issueCommentDraft.screenshots.filter((screenshot) => screenshot.id !== id)
}
})),
setAgentDraft: (draft) =>
set((state) => ({
agentDraft: {
...state.agentDraft,
...draft
}
})),
startNewAgent: () =>
set({
agentDraft: {
...EMPTY_AGENT_DRAFT,
id: crypto.randomUUID()
},
editingAgentId: null
}),
editAgent: (id) =>
set((state) => {
const agent = state.agents.find((item) => item.id === id);
return agent
? {
agentDraft: { ...agent },
editingAgentId: id
}
: {};
}),
saveAgent: async () => {
const state = appStore.getState();
const draft = normalizeAgent(
{
...state.agentDraft,
id: state.editingAgentId ?? state.agentDraft.id ?? crypto.randomUUID()
},
state.agents.length
);
if (!draft.name.trim() || !draft.file.trim() || !draft.content.trim()) {
setStatus(set, "Agent name, file, and content are required.", "error");
return;
}
const agents = state.editingAgentId
? state.agents.map((agent) => (agent.id === state.editingAgentId ? draft : agent))
: [...state.agents, draft];
const normalizedAgents = normalizeAgents(agents);
await storageSet(AGENTS_STORAGE_KEY, normalizedAgents);
set((current) => ({
agents: normalizedAgents,
issueDraft: {
...current.issueDraft,
agentId: normalizedAgents.some((agent) => agent.id === current.issueDraft.agentId)
? current.issueDraft.agentId
: normalizedAgents[0].id
},
agentDraft: {
...draft
},
editingAgentId: draft.id
}));
setStatus(set, `Saved ${draft.name}.`, "success");
},
deleteAgent: async (id) => {
const state = appStore.getState();
const agent = state.agents.find((item) => item.id === id);
if (!agent) {
setStatus(set, "Could not find that agent.", "error");
return;
}
if (state.agents.length <= 1) {
setStatus(set, "At least one agent must remain available.", "error");
return;
}
const agents = state.agents.filter((item) => item.id !== id);
await storageSet(AGENTS_STORAGE_KEY, agents);
set((current) => ({
agents,
issueDraft: {
...current.issueDraft,
agentId: current.issueDraft.agentId === id ? agents[0].id : current.issueDraft.agentId
},
agentDraft: current.editingAgentId === id ? EMPTY_AGENT_DRAFT : current.agentDraft,
editingAgentId: current.editingAgentId === id ? null : current.editingAgentId
}));
setStatus(set, `Deleted ${agent.name}.`, "success");
},
openScreenshotPreview: (target, id) =>
set({
screenshotPreview: {
@@ -1008,7 +1175,12 @@ export const appStore = createStore<AppState>()((set, get) => ({
buildIssueBody(state, target.knownUrl),
[changeRequestLabel.id]
);
const agent = getAgentById(state.issueDraft.agentId, state.agents);
const uploads = [
{
name: agent.file,
blob: agentContentBlob(renderAgentContent(agent, state, target.knownUrl, issue.number))
},
...state.issueDraft.screenshots.map((screenshot) => ({
name: screenshot.name,
blob: screenshot.blob
@@ -1032,7 +1204,7 @@ export const appStore = createStore<AppState>()((set, get) => ({
issueDraft: {
subject: "",
content: "",
agentId: AVAILABLE_AGENTS[0].id,
agentId: appStore.getState().agents[0]?.id ?? AVAILABLE_AGENTS[0].id,
ready: false,
screenshots: [],
files: []
@@ -1090,20 +1262,32 @@ export const appStore = createStore<AppState>()((set, get) => ({
setStatus(set, "Loading workspace and Gitea context...", "neutral", { autoClear: false });
try {
const [localContext, repos, userUrls, deletedUrlBases, activeTabUrl] = await Promise.all([
const [localContext, repos, userUrls, deletedUrlBases, agents, activeTabUrl] = await Promise.all([
loadLocalContext(),
getReposOrderedByLastCommit(),
storageGet<KnownUrl[]>(USER_URLS_STORAGE_KEY, []),
storageGet<string[]>(DELETED_URL_BASES_STORAGE_KEY, []),
storageGet<AgentProfile[]>(AGENTS_STORAGE_KEY, AVAILABLE_AGENTS),
getActiveTabUrl()
]);
const activeBaseUrl = getBaseUrl(activeTabUrl);
const normalizedAgents = normalizeAgents(agents);
set({
localContext,
repos,
userUrls: userUrls.filter((url) => !deletedUrlBases.includes(getBaseUrl(url.url))),
deletedUrlBases,
agents: normalizedAgents,
issueDraft: {
...get().issueDraft,
agentId: normalizedAgents.some((agent) => agent.id === get().issueDraft.agentId)
? get().issueDraft.agentId
: normalizedAgents[0].id
},
agentDraft: normalizedAgents.find((agent) => agent.id === get().editingAgentId) ?? normalizedAgents[0],
editingAgentId:
normalizedAgents.find((agent) => agent.id === get().editingAgentId)?.id ?? normalizedAgents[0].id,
activeTabUrl,
activeBaseUrl
});
+79 -22
View File
@@ -197,6 +197,10 @@ h1 {
grid-template-columns: minmax(150px, 1fr) auto;
}
.agent-row {
grid-template-columns: minmax(0, 1fr) auto;
}
.workspace-row:first-child,
.url-row:first-child,
.agent-row:first-child {
@@ -345,6 +349,17 @@ a.item-title:hover,
gap: 3px;
}
.agent-actions,
.agent-editor-actions {
display: flex;
align-items: center;
gap: 4px;
}
.agent-editor-actions .primary-button {
width: auto;
}
.danger-button {
background: #fee4e2;
color: #9f1d14;
@@ -617,7 +632,7 @@ input[readonly] {
background: #f8fafc;
}
.target-summary > span:first-child,
.target-label,
.attachment-heading > span {
color: #52616f;
font-size: 10px;
@@ -777,35 +792,76 @@ input[readonly] {
.crop-box {
position: absolute;
display: block;
border: 2px solid #f59e0b;
background: rgba(245, 158, 11, 0.12);
pointer-events: none;
padding: 0;
background: transparent;
cursor: move;
pointer-events: auto;
touch-action: none;
}
.crop-controls {
display: grid;
gap: 5px;
.crop-handle {
position: absolute;
z-index: 1;
width: 11px;
height: 11px;
border: 2px solid #ffffff;
border-radius: 2px;
background: #f59e0b;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.24);
}
.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-handle-nw {
top: -7px;
left: -7px;
cursor: nwse-resize;
}
.crop-controls input {
width: 100%;
.crop-handle-n {
top: -7px;
left: 50%;
transform: translateX(-50%);
cursor: ns-resize;
}
.crop-controls output {
color: #334e68;
font-variant-numeric: tabular-nums;
text-align: right;
.crop-handle-ne {
top: -7px;
right: -7px;
cursor: nesw-resize;
}
.crop-handle-e {
top: 50%;
right: -7px;
transform: translateY(-50%);
cursor: ew-resize;
}
.crop-handle-se {
right: -7px;
bottom: -7px;
cursor: nwse-resize;
}
.crop-handle-s {
bottom: -7px;
left: 50%;
transform: translateX(-50%);
cursor: ns-resize;
}
.crop-handle-sw {
bottom: -7px;
left: -7px;
cursor: nesw-resize;
}
.crop-handle-w {
top: 50%;
left: -7px;
transform: translateY(-50%);
cursor: ew-resize;
}
.form-stack .checkbox-row {
@@ -885,7 +941,8 @@ input[readonly] {
}
.workspace-row,
.url-row {
.url-row,
.agent-row {
grid-template-columns: 1fr;
}
}