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
+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;
}
}