Add URL deletion and improve snapshots
Build / build (push) Successful in 11s

This commit is contained in:
2026-07-17 15:51:15 -05:00
parent dcc800fdfc
commit 489f65e0e9
9 changed files with 223 additions and 24 deletions
+3 -2
View File
@@ -40,15 +40,16 @@ Use `.env.local` for local secrets. Keep `.env.local` and any real token-bearing
- `scripts/generate-local-context.mjs` generates `public/local-context.json` from `/Users/Tabitha/.openclaw/workspace_Father` before builds.
- `public/local-context.json` is a committed fallback snapshot so CI builds can still produce a self-contained `dist/` if the local workspace root is unavailable.
- `npm run build` runs `scripts/verify-dist.mjs` after Vite to ensure the generated output has the required extension files.
- Host permissions are derived from `VITE_GITEA_BASE_URL`. Rebuild after changing the base URL.
- Host permissions include ordinary `http`/`https` pages for screenshot capture plus the Gitea host derived from `VITE_GITEA_BASE_URL`. Rebuild after changing the base URL.
- 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.
- 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.
- If the Ready checkbox is checked, create the issue first, upload screenshots/files as issue assets second, and apply the `ready` label last.
- The extension requests `activeTab` so screenshots can be captured from the current Chrome tab.
- The extension requests `activeTab` and `http`/`https` host permissions so screenshots can be captured from the current Chrome tab even after the side panel has stayed open across tab changes.
## Chrome Extension Management
+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. Add Issue is enabled when the active tab base URL is already linked to a Gitea repo; it creates an issue, uploads screenshots and files as issue assets, and can apply the `ready` label after uploads.
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, uploads screenshots and files as issue assets, and can apply the `ready` label after uploads.
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.
+8 -1
View File
@@ -39,10 +39,17 @@ 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.
- Screenshot capture uses Chrome's visible-tab capture API and requires the extension's `activeTab` permission.
- Screenshot capture uses Chrome's visible-tab capture API and requires the extension's `activeTab` permission plus ordinary `http`/`https` host permissions 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`.
- When Ready is checked, create the issue, upload screenshots/files as Gitea issue assets, then apply the `ready` label last.
## URL Management
- URLs can be deleted from the URLs list.
- 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 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, 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. 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, accepts file attachments, creates the Gitea issue, uploads assets, then applies the `ready` label if requested. Created issue bodies include `Chrome profile: Silma`. 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, 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, uploads assets, then applies the `ready` label if requested. Created issue bodies include `Chrome profile: Silma`. 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`.
+7 -1
View File
@@ -49,7 +49,8 @@
],
"repo_ordering": "Gitea repos are ordered by latest default-branch commit timestamp.",
"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.",
"add_issue_behavior": "The form shows the target repo for the active known URL, captures visible-tab screenshots, accepts file attachments, creates a Gitea issue, uploads screenshots/files as issue assets, then applies the ready label after uploads when requested. Issue bodies include Chrome profile: Silma.",
"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, uploads screenshots/files as issue assets, then applies the ready label after uploads when requested. Issue bodies include Chrome profile: Silma.",
"known_urls": [
{
"url": "http://100.66.226.22:23030/",
@@ -77,6 +78,11 @@
"storage",
"tabs"
],
"extension_host_permissions": [
"http://*/*",
"https://*/*",
"Gitea host derived from VITE_GITEA_BASE_URL"
],
"commands": {
"install": "npm ci",
"codex_setup": "npm run codex:setup",
+60 -8
View File
@@ -5,7 +5,8 @@ import {
CHROME_PROFILE_NAME,
findKnownUrlForBase,
type AddUrlMode,
type AppView
type AppView,
type ScreenshotAttachment
} from "./store";
import type { GiteaRepoSummary } from "./api";
import type { KnownUrl, LocalContext, LocalWorkspace } from "./localContext";
@@ -61,7 +62,26 @@ function formatSha(value: string): string {
function allUrls(): KnownUrl[] {
const state = appStore.getState();
return [...(state.localContext?.knownUrls ?? []), ...state.userUrls];
const deletedBases = new Set(state.deletedUrlBases);
const urls = [...(state.localContext?.knownUrls ?? []), ...state.userUrls].filter(
(knownUrl) => !deletedBases.has(baseUrlFor(knownUrl.url))
);
return urls.filter(
(knownUrl, index) => urls.findIndex((item) => baseUrlFor(item.url) === baseUrlFor(knownUrl.url)) === index
);
}
function baseUrlFor(value: string): string {
try {
const url = new URL(value);
if (url.protocol !== "http:" && url.protocol !== "https:") {
return "";
}
return `${url.origin}/`;
} catch {
return "";
}
}
function isActiveBaseUrlKnown(): boolean {
@@ -167,7 +187,10 @@ function renderUrls(urls: KnownUrl[]): string {
: `<span class="muted">No Gitea repo link established</span>`
}
</div>
<button class="visit-button" type="button" data-open-url="${knownUrl.url}">Quick visit</button>
<div class="url-actions">
<button class="visit-button" type="button" data-open-url="${knownUrl.url}">Quick visit</button>
<button class="danger-button" type="button" data-delete-url="${knownUrl.url}">Delete</button>
</div>
</article>
`
)
@@ -271,6 +294,27 @@ function renderAttachmentList(items: string[], emptyText: string): string {
`;
}
function renderScreenshotList(screenshots: ScreenshotAttachment[]): string {
if (!screenshots.length) {
return `<span class="muted">No screenshots captured.</span>`;
}
return `
<div class="screenshot-list">
${screenshots
.map(
(screenshot) => `
<figure class="screenshot-item">
<img src="${screenshot.dataUrl}" alt="${escapeHtml(screenshot.name)}" />
<figcaption>${escapeHtml(screenshot.name)}</figcaption>
</figure>
`
)
.join("")}
</div>
`;
}
function renderAddIssue(): string {
const state = appStore.getState();
const knownUrl = getActiveKnownUrl();
@@ -308,12 +352,11 @@ function renderAddIssue(): string {
<section class="attachment-section">
<div class="attachment-heading">
<span>Screenshots</span>
<button id="capture-screenshot" class="secondary-button" type="button">Take snapshot</button>
<button id="capture-screenshot" class="secondary-button" type="button" ${state.isCapturingScreenshot ? "disabled" : ""}>
${state.isCapturingScreenshot ? "Taking..." : "Take snapshot"}
</button>
</div>
${renderAttachmentList(
draft.screenshots.map((screenshot) => screenshot.name),
"No screenshots captured."
)}
${renderScreenshotList(draft.screenshots)}
</section>
<label>
@@ -357,6 +400,15 @@ function bindViewActions(): void {
});
});
viewContent?.querySelectorAll<HTMLButtonElement>("[data-delete-url]").forEach((button) => {
button.addEventListener("click", () => {
const url = button.dataset.deleteUrl;
if (url) {
void appStore.getState().deleteUrlLink(url);
}
});
});
viewContent?.querySelectorAll<HTMLInputElement>("input[name='add-url-mode']").forEach((input) => {
input.addEventListener("change", () => {
appStore.getState().setAddUrlDraft({
+85 -7
View File
@@ -46,12 +46,14 @@ interface AppState {
repos: GiteaRepoSummary[];
localContext: LocalContext | null;
userUrls: KnownUrl[];
deletedUrlBases: string[];
activeTabUrl: string;
activeBaseUrl: string;
addUrlDraft: AddUrlDraft;
issueDraft: IssueDraft;
isLoadingData: boolean;
isTestingConnection: boolean;
isCapturingScreenshot: boolean;
isCreatingIssue: boolean;
statusMessage: string;
statusTone: StatusTone;
@@ -65,10 +67,12 @@ interface AppState {
refreshActiveTab: () => Promise<void>;
loadData: () => Promise<void>;
saveCurrentUrlLink: () => Promise<void>;
deleteUrlLink: (url: string) => Promise<void>;
testConnection: () => Promise<void>;
}
const USER_URLS_STORAGE_KEY = "silmaAide.userUrls";
const DELETED_URL_BASES_STORAGE_KEY = "silmaAide.deletedUrlBases";
let statusTimer: number | undefined;
function getBaseUrl(value: string): string {
@@ -114,8 +118,35 @@ function getActiveTabUrl(): Promise<string> {
return;
}
chrome.tabs.query({ active: true, lastFocusedWindow: true }, (tabs) => {
resolve(tabs[0]?.url ?? "");
getActiveBrowserTab()
.then((tab) => resolve(tab.url ?? ""))
.catch(() => resolve(""));
});
}
function getActiveBrowserTab(): Promise<chrome.tabs.Tab> {
return new Promise((resolve, reject) => {
if (!globalThis.chrome?.tabs?.query) {
reject(new Error("Chrome tab lookup is only available in the extension side panel."));
return;
}
chrome.tabs.query({ active: true, currentWindow: true }, (currentWindowTabs) => {
const currentWindowTab = currentWindowTabs[0];
if (currentWindowTab?.id !== undefined) {
resolve(currentWindowTab);
return;
}
chrome.tabs.query({ active: true, lastFocusedWindow: true }, (lastFocusedTabs) => {
const lastFocusedTab = lastFocusedTabs[0];
if (lastFocusedTab?.id !== undefined) {
resolve(lastFocusedTab);
return;
}
reject(new Error("Could not find the active browser tab."));
});
});
});
}
@@ -129,7 +160,14 @@ function findRepoByFullName(context: LocalContext, fullName: string): LocalGitea
}
function allKnownUrls(state: AppState): KnownUrl[] {
return [...(state.localContext?.knownUrls ?? []), ...state.userUrls];
const deletedBases = new Set(state.deletedUrlBases);
const urls = [...(state.localContext?.knownUrls ?? []), ...state.userUrls].filter(
(knownUrl) => !deletedBases.has(getBaseUrl(knownUrl.url))
);
return urls.filter(
(knownUrl, index) => urls.findIndex((item) => getBaseUrl(item.url) === getBaseUrl(knownUrl.url)) === index
);
}
export function findKnownUrlForBase(state: AppState): KnownUrl | null {
@@ -228,8 +266,13 @@ function captureVisibleTab(): Promise<{ dataUrl: string; blob: Blob }> {
return;
}
chrome.tabs.query({ active: true, lastFocusedWindow: true }, (tabs) => {
const windowId = tabs[0]?.windowId;
getActiveBrowserTab().then((tab) => {
if (!tab.url || !getBaseUrl(tab.url)) {
reject(new Error("Open an http or https tab before taking a snapshot."));
return;
}
const windowId = tab.windowId;
chrome.tabs.captureVisibleTab(windowId, { format: "png" }, async (dataUrl) => {
const lastError = chrome.runtime?.lastError;
if (lastError) {
@@ -252,6 +295,8 @@ function captureVisibleTab(): Promise<{ dataUrl: string; blob: Blob }> {
reject(new Error("Could not prepare the screenshot for upload."));
}
});
}).catch((error: unknown) => {
reject(error instanceof Error ? error : new Error("Could not find the active browser tab."));
});
});
}
@@ -277,6 +322,7 @@ export const appStore = createStore<AppState>()((set, get) => ({
repos: [],
localContext: null,
userUrls: [],
deletedUrlBases: [],
activeTabUrl: "",
activeBaseUrl: "",
addUrlDraft: {
@@ -292,6 +338,7 @@ export const appStore = createStore<AppState>()((set, get) => ({
},
isLoadingData: false,
isTestingConnection: false,
isCapturingScreenshot: false,
isCreatingIssue: false,
statusMessage: "",
statusTone: "neutral",
@@ -325,6 +372,7 @@ export const appStore = createStore<AppState>()((set, get) => ({
}
})),
captureScreenshot: async () => {
set({ isCapturingScreenshot: true });
setStatus(set, "Capturing screenshot...", "neutral", { autoClear: false });
try {
@@ -346,6 +394,8 @@ export const appStore = createStore<AppState>()((set, get) => ({
} catch (error) {
const message = error instanceof Error ? error.message : "Could not capture a screenshot.";
setStatus(set, message, "error");
} finally {
set({ isCapturingScreenshot: false });
}
},
createIssue: async () => {
@@ -427,10 +477,11 @@ export const appStore = createStore<AppState>()((set, get) => ({
setStatus(set, "Loading workspace and Gitea context...", "neutral", { autoClear: false });
try {
const [localContext, repos, userUrls, activeTabUrl] = await Promise.all([
const [localContext, repos, userUrls, deletedUrlBases, activeTabUrl] = await Promise.all([
loadLocalContext(),
getReposOrderedByLastCommit(),
storageGet<KnownUrl[]>(USER_URLS_STORAGE_KEY, []),
storageGet<string[]>(DELETED_URL_BASES_STORAGE_KEY, []),
getActiveTabUrl()
]);
const activeBaseUrl = getBaseUrl(activeTabUrl);
@@ -438,7 +489,8 @@ export const appStore = createStore<AppState>()((set, get) => ({
set({
localContext,
repos,
userUrls,
userUrls: userUrls.filter((url) => !deletedUrlBases.includes(getBaseUrl(url.url))),
deletedUrlBases,
activeTabUrl,
activeBaseUrl
});
@@ -469,15 +521,41 @@ export const appStore = createStore<AppState>()((set, get) => ({
...state.userUrls.filter((url) => getBaseUrl(url.url) !== state.activeBaseUrl),
link
];
const deletedUrlBases = state.deletedUrlBases.filter((baseUrl) => baseUrl !== state.activeBaseUrl);
await storageSet(USER_URLS_STORAGE_KEY, userUrls);
await storageSet(DELETED_URL_BASES_STORAGE_KEY, deletedUrlBases);
set({
userUrls,
deletedUrlBases,
activeView: "urls",
addUrlDraft: { mode: null, selectedValue: "" }
});
setStatus(set, `Linked ${state.activeBaseUrl} to ${link.giteaRepo?.fullName || link.workspaceName}.`, "success");
},
deleteUrlLink: async (url) => {
const state = appStore.getState();
const baseUrl = getBaseUrl(url);
if (!baseUrl) {
setStatus(set, "Could not delete that URL because it is not a valid http or https URL.", "error");
return;
}
const userUrls = state.userUrls.filter((knownUrl) => getBaseUrl(knownUrl.url) !== baseUrl);
const deletedUrlBases = state.deletedUrlBases.includes(baseUrl)
? state.deletedUrlBases
: [...state.deletedUrlBases, baseUrl];
await storageSet(USER_URLS_STORAGE_KEY, userUrls);
await storageSet(DELETED_URL_BASES_STORAGE_KEY, deletedUrlBases);
set({
userUrls,
deletedUrlBases,
activeView: "urls"
});
setStatus(set, `Deleted ${baseUrl} from URLs.`, "success");
},
testConnection: async () => {
set({
connectedUser: null,
+53
View File
@@ -71,6 +71,7 @@ h1 {
.tool-button,
.icon-button,
.visit-button,
.danger-button,
.primary-button {
border: 0;
}
@@ -320,6 +321,7 @@ a.item-title:hover,
}
.visit-button,
.danger-button,
.secondary-button,
.primary-button {
flex: 0 0 auto;
@@ -332,12 +334,27 @@ a.item-title:hover,
font-weight: 850;
}
.url-actions {
display: grid;
gap: 3px;
}
.danger-button {
background: #fee4e2;
color: #9f1d14;
}
.secondary-button {
border: 1px solid #9fb3c8;
background: #eef4f9;
color: #243b53;
}
.secondary-button:disabled {
cursor: wait;
opacity: 0.6;
}
.primary-button {
width: 100%;
min-height: 34px;
@@ -466,6 +483,42 @@ input[readonly] {
line-height: 1.35;
}
.screenshot-list {
display: grid;
gap: 5px;
}
.screenshot-item {
display: grid;
grid-template-columns: 64px minmax(0, 1fr);
align-items: center;
gap: 7px;
min-width: 0;
margin: 0;
border: 1px solid #d9e2ec;
border-radius: 6px;
padding: 4px;
background: #ffffff;
}
.screenshot-item img {
display: block;
width: 64px;
height: 40px;
border-radius: 4px;
object-fit: cover;
}
.screenshot-item figcaption {
min-width: 0;
overflow: hidden;
color: #334e68;
font-size: 10px;
line-height: 1.25;
text-overflow: ellipsis;
white-space: nowrap;
}
.checkbox-row {
display: flex;
align-items: center;
+5 -3
View File
@@ -18,16 +18,18 @@ function loadExtensionEnv(mode: string): NodeJS.ProcessEnv {
}
function getGiteaHostPermissions(baseUrl?: string): string[] {
const permissions = ["http://*/*", "https://*/*"];
if (!baseUrl?.trim()) {
return [];
return permissions;
}
try {
const url = new URL(baseUrl);
return [`${url.protocol}//${url.host}/*`];
return Array.from(new Set([...permissions, `${url.protocol}//${url.host}/*`]));
} catch {
console.warn(`VITE_GITEA_BASE_URL is not a valid URL: ${baseUrl}`);
return [];
return permissions;
}
}