Files
mp-silma-ai-aide/src/store.ts
T
2026-07-21 07:42:30 -05:00

2327 lines
74 KiB
TypeScript

import { createStore } from "zustand/vanilla";
import {
createGiteaIssueComment,
createGiteaIssue,
deleteGiteaIssue,
deleteGiteaIssueAttachment,
ensureGiteaLabel,
getCurrentGiteaUser,
getGiteaIssueDetail,
getGiteaIssues,
getReposOrderedByLastCommit,
giteaConfig,
setGiteaIssueLabels,
type GiteaIssueAttachment,
type GiteaIssueDetail,
type GiteaIssueStateFilter,
type GiteaIssueSummary,
type GiteaRepoSummary,
type GiteaUser,
updateGiteaIssue,
uploadGiteaIssueAttachment
} from "./api";
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" | "prompts" | "agents" | "open-issues" | "add-url" | "add-issue";
export type AddUrlMode = "workspace" | "repo";
export type ScreenshotTarget = "issue" | "comment" | "issue-attachment";
export type OpenIssuesStatusFilter = "open" | "closed";
export type AppDialog = "add-url" | "add-workspace";
export const CHROME_PROFILE_NAME = "Silma";
export const ISSUE_LABEL_OPTIONS = [
"Change Request",
"Idea",
"Tabitha",
"Orson",
"Belisarius",
"Ozymandias",
"Ready"
] as const;
export type IssueLabelOption = (typeof ISSUE_LABEL_OPTIONS)[number];
export interface AgentProfile {
id: string;
name: string;
file: string;
description: string;
content: string;
}
export interface WorkspacePrompt {
id: string;
name: string;
workspacePath: 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.
- After the implementation has been pushed to {{git}}, close {{issue}} in Gitea.
- If delivery is blocked, partial, or risky, leave a Gitea comment on {{issue}} with the blocker, current status, evidence, and recommended next step.
- When handing off to another agent, include current status, touched files, verification results, and any unresolved decisions.`;
const GLOBAL_AGENT_COMPLETION_RULES = [
"- After the implementation has been pushed to {{git}}, close {{issue}} in Gitea.",
"- If delivery is blocked, partial, or risky, leave a Gitea comment on {{issue}} with the blocker, current status, evidence, and recommended next step."
];
export const AVAILABLE_AGENTS: AgentProfile[] = [
{
id: "global-agent",
name: "GLOBAL AGENT",
file: "AGENTS.md",
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: ""
};
const EMPTY_PROMPT_DRAFT: WorkspacePrompt = {
id: "",
name: "",
workspacePath: "",
content: ""
};
interface AddUrlDraft {
mode: AddUrlMode | null;
selectedValue: string;
}
interface WorkspaceDraft {
name: string;
path: string;
search: string;
}
export interface ScreenshotAttachment {
id: string;
name: string;
dataUrl: string;
blob: Blob;
}
export interface OpenIssue extends GiteaIssueSummary {
repoFullName: string;
repoWebUrl: string;
owner: string;
repo: string;
knownUrl: KnownUrl | null;
}
interface IssueDraft {
subject: string;
content: string;
promptId: string;
agentId: string;
labels: IssueLabelOption[];
screenshots: ScreenshotAttachment[];
files: File[];
}
interface IssueCommentDraft {
content: string;
screenshots: ScreenshotAttachment[];
files: File[];
}
interface IssueAttachmentDraft {
screenshots: ScreenshotAttachment[];
files: File[];
}
interface IssueEditDraft {
subject: string;
content: string;
labels: IssueLabelOption[];
}
interface CropArea {
left: number;
top: number;
width: number;
height: number;
}
interface ScreenshotPreview {
target: ScreenshotTarget;
id: string;
crop: CropArea;
}
interface AppState {
activeView: AppView;
activeDialog: AppDialog | null;
config: typeof giteaConfig;
connectedUser: GiteaUser | null;
repos: GiteaRepoSummary[];
localContext: LocalContext | null;
userWorkspaces: LocalWorkspace[];
userUrls: KnownUrl[];
deletedUrlBases: string[];
activeTabUrl: string;
activeBaseUrl: string;
addUrlDraft: AddUrlDraft;
workspaceDraft: WorkspaceDraft;
issueDraft: IssueDraft;
issueCommentDraft: IssueCommentDraft;
issueAttachmentDraft: IssueAttachmentDraft;
agents: AgentProfile[];
agentDraft: AgentProfile;
editingAgentId: string | null;
prompts: WorkspacePrompt[];
promptDraft: WorkspacePrompt;
editingPromptId: string | null;
screenshotPreview: ScreenshotPreview | null;
targetIssues: GiteaIssueSummary[];
openIssues: OpenIssue[];
openIssuesLimitToCurrentRepo: boolean;
openIssuesStatusFilters: OpenIssuesStatusFilter[];
openIssuesLabelFilter: IssueLabelOption | "";
openIssuesScopeKey: string;
issueListRepoFullName: string;
selectedIssueNumber: number | null;
selectedIssueRepoFullName: string;
selectedIssueDetail: GiteaIssueDetail | null;
selectedIssueDetailNumber: number | null;
selectedIssueEditDraft: IssueEditDraft;
isLoadingData: boolean;
isLoadingTargetIssues: boolean;
isLoadingIssueDetail: boolean;
isTestingConnection: boolean;
isCapturingScreenshot: boolean;
isCapturingIssueCommentScreenshot: boolean;
isCapturingIssueAttachmentScreenshot: boolean;
isCreatingIssue: boolean;
isCreatingIssueComment: boolean;
isUploadingIssueAttachments: boolean;
isUpdatingIssue: boolean;
statusMessage: string;
statusTone: StatusTone;
clearStatus: () => void;
setActiveView: (view: AppView) => void;
openDialog: (dialog: AppDialog) => void;
closeDialog: () => void;
setAddUrlDraft: (draft: Partial<AddUrlDraft>) => void;
setWorkspaceDraft: (draft: Partial<WorkspaceDraft>) => void;
saveWorkspace: () => Promise<void>;
deleteUserWorkspace: (path: string) => Promise<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;
setIssueAttachmentFiles: (files: File[]) => void;
deleteIssueAttachmentFile: (index: number) => void;
deleteIssueAttachmentScreenshot: (id: string) => void;
setAgentDraft: (draft: Partial<AgentProfile>) => void;
startNewAgent: () => void;
editAgent: (id: string) => void;
saveAgent: () => Promise<void>;
deleteAgent: (id: string) => Promise<void>;
setPromptDraft: (draft: Partial<WorkspacePrompt>) => void;
startNewPrompt: () => void;
editPrompt: (id: string) => void;
savePrompt: () => Promise<void>;
deletePrompt: (id: string) => Promise<void>;
setIssuePrompt: (promptId: string) => void;
openScreenshotPreview: (target: ScreenshotTarget, id: string) => void;
closeScreenshotPreview: () => void;
setScreenshotPreviewCrop: (crop: Partial<CropArea>) => void;
applyScreenshotCrop: () => Promise<void>;
removePreviewScreenshot: () => void;
setOpenIssuesLimit: (limitToCurrentRepo: boolean) => void;
setOpenIssuesStatusFilters: (statusFilters: OpenIssuesStatusFilter[]) => void;
setOpenIssuesLabelFilter: (labelFilter: IssueLabelOption | "") => void;
setSelectedIssueEditDraft: (draft: Partial<IssueEditDraft>) => void;
saveSelectedIssueEdits: () => Promise<void>;
clearSelectedIssue: () => void;
selectIssue: (issueNumber: number, repoFullName?: string) => void;
loadOpenIssues: (force?: boolean) => Promise<void>;
loadTargetIssues: (force?: boolean) => Promise<void>;
loadSelectedIssueDetail: (force?: boolean) => Promise<void>;
deleteIssue: (issueNumber: number, repoFullName?: string) => Promise<void>;
deleteSelectedIssueAttachment: (attachmentId: number) => Promise<void>;
captureIssueCommentScreenshot: () => Promise<void>;
captureIssueAttachmentScreenshot: () => Promise<void>;
uploadSelectedIssueAttachments: () => Promise<void>;
createIssueComment: () => Promise<void>;
captureScreenshot: () => Promise<void>;
createIssue: () => Promise<void>;
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 USER_WORKSPACES_STORAGE_KEY = "silmaAide.userWorkspaces";
const DELETED_URL_BASES_STORAGE_KEY = "silmaAide.deletedUrlBases";
const AGENTS_STORAGE_KEY = "silmaAide.agents";
const PROMPTS_STORAGE_KEY = "silmaAide.prompts";
let statusTimer: number | undefined;
function getBaseUrl(value: string): string {
try {
const url = new URL(value);
if (url.protocol !== "http:" && url.protocol !== "https:") {
return "";
}
return `${url.origin}/`;
} catch {
return "";
}
}
function storageGet<T>(key: string, fallback: T): Promise<T> {
return new Promise((resolve) => {
if (!globalThis.chrome?.storage?.local) {
resolve(fallback);
return;
}
chrome.storage.local.get(key, (result) => {
resolve((result[key] as T | undefined) ?? fallback);
});
});
}
function storageSet(key: string, value: unknown): Promise<void> {
return new Promise((resolve) => {
if (!globalThis.chrome?.storage?.local) {
resolve();
return;
}
chrome.storage.local.set({ [key]: value }, () => resolve());
});
}
function getActiveTabUrl(): Promise<string> {
return new Promise((resolve) => {
if (!globalThis.chrome?.tabs?.query) {
resolve(globalThis.location.href);
return;
}
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."));
});
});
});
}
export function getAllWorkspaces(state: Pick<AppState, "localContext" | "userWorkspaces">): LocalWorkspace[] {
const workspaces = [...(state.localContext?.workspaces ?? []), ...state.userWorkspaces];
return workspaces.filter(
(workspace, index) => workspaces.findIndex((item) => item.path === workspace.path) === index
);
}
function findWorkspaceByName(state: AppState, name: string): LocalWorkspace | undefined {
return getAllWorkspaces(state).find((workspace) => workspace.name === name);
}
function findWorkspaceByPath(state: AppState, path: string): LocalWorkspace | undefined {
return getAllWorkspaces(state).find((workspace) => workspace.path === path);
}
function findRepoByFullName(context: LocalContext, fullName: string): LocalGiteaRepoLink | null {
return context.gitWorktrees.find((worktree) => worktree.giteaRepo?.fullName === fullName)?.giteaRepo ?? null;
}
function allKnownUrls(state: AppState): KnownUrl[] {
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
);
}
function findKnownUrlByBase(state: AppState, baseUrl: string): KnownUrl | null {
if (!baseUrl) {
return null;
}
return allKnownUrls(state).find((knownUrl) => getBaseUrl(knownUrl.url) === baseUrl) ?? null;
}
export function findKnownUrlForBase(state: AppState): KnownUrl | null {
return findKnownUrlByBase(state, state.activeBaseUrl);
}
function parseRepoFullName(fullName: string): { owner: string; repo: string } | null {
const [owner, ...repoParts] = fullName.split("/");
const repo = repoParts.join("/");
if (!owner || !repo) {
return null;
}
return { owner, repo };
}
interface RepoTarget {
knownUrl: KnownUrl | null;
owner: string;
repo: string;
fullName: string;
webUrl: string;
}
function getTargetRepo(state: AppState): RepoTarget | null {
const knownUrl = findKnownUrlForBase(state);
const parsed = knownUrl?.giteaRepo ? parseRepoFullName(knownUrl.giteaRepo.fullName) : null;
if (!knownUrl || !knownUrl.giteaRepo || !parsed) {
return null;
}
return {
knownUrl,
owner: parsed.owner,
repo: parsed.repo,
fullName: knownUrl.giteaRepo.fullName,
webUrl: knownUrl.giteaRepo.webUrl
};
}
function getKnownUrlForRepo(state: AppState, repoFullName: string): KnownUrl | null {
return allKnownUrls(state).find((knownUrl) => knownUrl.giteaRepo?.fullName === repoFullName) ?? null;
}
function getRepoTargetByFullName(state: AppState, repoFullName: string): RepoTarget | null {
const parsed = parseRepoFullName(repoFullName);
if (!parsed) {
return null;
}
const knownUrl = getKnownUrlForRepo(state, repoFullName);
const repoSummary = state.repos.find((repo) => repo.fullName === repoFullName);
return {
knownUrl,
owner: parsed.owner,
repo: parsed.repo,
fullName: repoFullName,
webUrl: knownUrl?.giteaRepo?.webUrl ?? repoSummary?.webUrl ?? `${giteaConfig.baseUrl.replace(/\/+$/, "")}/${repoFullName}`
};
}
function getIssueTarget(state: AppState, repoFullName = state.selectedIssueRepoFullName): RepoTarget | null {
if (repoFullName) {
return getRepoTargetByFullName(state, repoFullName);
}
return getTargetRepo(state);
}
function getOpenIssuesScopeKey(state: AppState): string {
const repoScope = state.openIssuesLimitToCurrentRepo ? `current:${getTargetRepo(state)?.fullName ?? "none"}` : "all-repos";
return `${repoScope}:${state.openIssuesStatusFilters.join(",")}:${state.openIssuesLabelFilter || "all-labels"}`;
}
function getOpenIssuesGiteaState(statusFilters: OpenIssuesStatusFilter[]): GiteaIssueStateFilter | null {
if (!statusFilters.length) {
return null;
}
return statusFilters.length === 1 ? statusFilters[0] : "all";
}
function issueHasLabel(issue: GiteaIssueSummary, labelFilter: IssueLabelOption | ""): boolean {
if (!labelFilter) {
return true;
}
return issue.labels.some((label) => label.toLowerCase() === labelFilter.toLowerCase());
}
function emptyIssueDrafts() {
return {
selectedIssueNumber: null,
selectedIssueRepoFullName: "",
selectedIssueDetail: null,
selectedIssueDetailNumber: null,
selectedIssueEditDraft: {
subject: "",
content: "",
labels: []
},
issueCommentDraft: {
content: "",
screenshots: [],
files: []
},
issueAttachmentDraft: {
screenshots: [],
files: []
}
};
}
function toOpenIssue(issue: GiteaIssueSummary, target: RepoTarget): OpenIssue {
return {
...issue,
repoFullName: target.fullName,
repoWebUrl: target.webUrl,
owner: target.owner,
repo: target.repo,
knownUrl: target.knownUrl
};
}
function inferUrlLink(context: LocalContext, baseUrl: string, draft: AddUrlDraft): KnownUrl | null {
if (!draft.mode || !draft.selectedValue) {
return null;
}
if (draft.mode === "workspace") {
const state = appStore.getState();
const workspace = findWorkspaceByPath(state, draft.selectedValue) ?? findWorkspaceByName(state, draft.selectedValue);
if (!workspace) {
return null;
}
const repo = workspace.linkedRepos[0] ?? null;
return {
label: new URL(baseUrl).host,
url: baseUrl,
workspaceName: workspace.name,
workspacePath: workspace.path,
giteaRepo: repo ? { fullName: repo.fullName, webUrl: repo.webUrl } : null,
source: "user"
};
}
const repo = findRepoByFullName(context, draft.selectedValue);
const worktree = context.gitWorktrees.find((item) => item.giteaRepo?.fullName === draft.selectedValue);
const workspace = worktree ? findWorkspaceByName(appStore.getState(), worktree.topWorkspaceName) : undefined;
if (!repo || !workspace) {
return null;
}
return {
label: new URL(baseUrl).host,
url: baseUrl,
workspaceName: workspace.name,
workspacePath: workspace.path,
giteaRepo: repo,
source: "user"
};
}
function scheduleStatusClear(set: (partial: Partial<AppState>) => void, delayMs = 5000): void {
if (statusTimer !== undefined) {
window.clearTimeout(statusTimer);
}
statusTimer = window.setTimeout(() => {
statusTimer = undefined;
set({
statusMessage: "",
statusTone: "neutral"
});
}, delayMs);
}
function setStatus(
set: (partial: Partial<AppState>) => void,
statusMessage: string,
statusTone: StatusTone,
options: { autoClear?: boolean } = {}
): void {
set({ statusMessage, statusTone });
if (options.autoClear ?? statusTone !== "neutral") {
scheduleStatusClear(set);
} else if (statusTimer !== undefined) {
window.clearTimeout(statusTimer);
statusTimer = undefined;
}
}
function captureVisibleTab(): Promise<{ dataUrl: string; blob: Blob }> {
return new Promise((resolve, reject) => {
if (!globalThis.chrome?.tabs?.captureVisibleTab) {
reject(new Error("Chrome screenshot capture is only available in the extension side panel."));
return;
}
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) {
reject(new Error(lastError.message));
return;
}
if (!dataUrl) {
reject(new Error("Chrome did not return a screenshot."));
return;
}
try {
const response = await fetch(dataUrl);
resolve({
dataUrl,
blob: await response.blob()
});
} catch {
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."));
});
});
}
function createScreenshotAttachment(dataUrl: string, blob: Blob): ScreenshotAttachment {
return {
id: crypto.randomUUID(),
name: `silma-screenshot-${new Date().toISOString().replace(/[:.]/g, "-")}.png`,
dataUrl,
blob
};
}
function normalizeAgent(agent: Partial<AgentProfile>, index: number): AgentProfile {
const normalized = {
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
};
if (normalized.id !== AVAILABLE_AGENTS[0].id) {
return normalized;
}
const missingRules = GLOBAL_AGENT_COMPLETION_RULES.filter((rule) => !normalized.content.includes(rule));
return missingRules.length
? {
...normalized,
content: `${normalized.content.trim()}\n${missingRules.join("\n")}`
}
: normalized;
}
function normalizeAgents(agents: AgentProfile[]): AgentProfile[] {
const normalized = agents.map(normalizeAgent);
return normalized.length ? normalized : AVAILABLE_AGENTS;
}
function getFallbackWorkspacePath(state: AppState): string {
return findKnownUrlForBase(state)?.workspacePath ?? getAllWorkspaces(state)[0]?.path ?? "";
}
function normalizePrompt(prompt: Partial<WorkspacePrompt>, fallbackWorkspacePath: string): WorkspacePrompt {
return {
id: prompt.id || crypto.randomUUID(),
name: prompt.name?.trim() || "Untitled prompt",
workspacePath: prompt.workspacePath?.trim() || fallbackWorkspacePath,
content: prompt.content?.trim() || ""
};
}
function normalizePrompts(prompts: WorkspacePrompt[], fallbackWorkspacePath: string): WorkspacePrompt[] {
return prompts
.map((prompt) => normalizePrompt(prompt, fallbackWorkspacePath))
.filter((prompt) => prompt.name && prompt.workspacePath);
}
function getPromptById(id: string, prompts: WorkspacePrompt[]): WorkspacePrompt | null {
return prompts.find((prompt) => prompt.id === id) ?? null;
}
function normalizeIssueLabels(labels: string[]): IssueLabelOption[] {
return ISSUE_LABEL_OPTIONS.filter((option) => labels.some((label) => label.toLowerCase() === option.toLowerCase()));
}
async function ensureIssueLabels(
owner: string,
repo: string,
labels: IssueLabelOption[]
): Promise<{ ids: number[]; names: string[] }> {
const ensuredLabels = await Promise.all(labels.map((label) => ensureGiteaLabel(owner, repo, label)));
return {
ids: ensuredLabels.map((label) => label.id),
names: ensuredLabels.map((label) => label.name)
};
}
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[] {
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, state.agents);
const requirements = state.issueDraft.content.trim() || "No requirements provided.";
const references = [
`- Source URL: ${state.activeTabUrl || knownUrl.url}`,
`- 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}`
];
return [`# Requirements`, requirements, `# References`, references.join("\n")].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)
.map((attachment) => `- [${attachment.name}](${attachment.downloadUrl})`);
return lines.length ? `Attachments:\n${lines.join("\n")}` : "";
}
function buildIssueCommentBody(state: AppState, knownUrl: KnownUrl | null, attachments: GiteaIssueAttachment[]): string {
const parts = [
state.issueCommentDraft.content.trim(),
buildAttachmentMarkdown(attachments),
"---",
`Source URL: ${state.activeTabUrl || knownUrl?.url || "Not linked to a known URL"}`,
`Base URL: ${state.activeBaseUrl || (knownUrl ? getBaseUrl(knownUrl.url) : "Not linked")}`,
`Chrome profile: ${CHROME_PROFILE_NAME}`
];
return parts.filter(Boolean).join("\n\n");
}
export const appStore = createStore<AppState>()((set, get) => ({
activeView: "repos",
activeDialog: null,
config: giteaConfig,
connectedUser: null,
repos: [],
localContext: null,
userWorkspaces: [],
userUrls: [],
deletedUrlBases: [],
activeTabUrl: "",
activeBaseUrl: "",
addUrlDraft: {
mode: null,
selectedValue: ""
},
workspaceDraft: {
name: "",
path: "",
search: ""
},
issueDraft: {
subject: "",
content: "",
promptId: "",
agentId: AVAILABLE_AGENTS[0].id,
labels: ["Change Request"],
screenshots: [],
files: []
},
issueCommentDraft: {
content: "",
screenshots: [],
files: []
},
issueAttachmentDraft: {
screenshots: [],
files: []
},
agents: AVAILABLE_AGENTS,
agentDraft: AVAILABLE_AGENTS[0],
editingAgentId: AVAILABLE_AGENTS[0].id,
prompts: [],
promptDraft: EMPTY_PROMPT_DRAFT,
editingPromptId: null,
screenshotPreview: null,
targetIssues: [],
openIssues: [],
openIssuesLimitToCurrentRepo: true,
openIssuesStatusFilters: ["open"],
openIssuesLabelFilter: "",
openIssuesScopeKey: "",
issueListRepoFullName: "",
selectedIssueNumber: null,
selectedIssueRepoFullName: "",
selectedIssueDetail: null,
selectedIssueDetailNumber: null,
selectedIssueEditDraft: {
subject: "",
content: "",
labels: []
},
isLoadingData: false,
isLoadingTargetIssues: false,
isLoadingIssueDetail: false,
isTestingConnection: false,
isCapturingScreenshot: false,
isCapturingIssueCommentScreenshot: false,
isCapturingIssueAttachmentScreenshot: false,
isCreatingIssue: false,
isCreatingIssueComment: false,
isUploadingIssueAttachments: false,
isUpdatingIssue: false,
statusMessage: "",
statusTone: "neutral",
clearStatus: () => {
if (statusTimer !== undefined) {
window.clearTimeout(statusTimer);
statusTimer = undefined;
}
set({ statusMessage: "", statusTone: "neutral" });
},
setActiveView: (view) =>
set((state) => ({
activeView: view,
promptDraft:
view === "prompts" && !state.editingPromptId
? {
...state.promptDraft,
workspacePath: getFallbackWorkspacePath(state)
}
: state.promptDraft
})),
openDialog: (dialog) =>
set((state) => ({
activeDialog: dialog,
addUrlDraft:
dialog === "add-url"
? {
mode: null,
selectedValue: ""
}
: state.addUrlDraft,
workspaceDraft:
dialog === "add-workspace"
? {
name: "",
path: "",
search: findKnownUrlForBase(state)?.workspaceName ?? ""
}
: state.workspaceDraft
})),
closeDialog: () => set({ activeDialog: null }),
setAddUrlDraft: (draft) =>
set((state) => ({
addUrlDraft: {
...state.addUrlDraft,
...draft
}
})),
setWorkspaceDraft: (draft) =>
set((state) => ({
workspaceDraft: {
...state.workspaceDraft,
...draft
}
})),
saveWorkspace: async () => {
const state = appStore.getState();
const path = state.workspaceDraft.path.trim().replace(/\/+$/, "");
const pathParts = path.split("/").filter(Boolean);
const name = state.workspaceDraft.name.trim() || pathParts[pathParts.length - 1] || "workspace";
if (!path.startsWith("/")) {
setStatus(set, "Workspace path must be an absolute local path.", "error");
return;
}
const workspace: LocalWorkspace = {
name,
path,
linkedRepos: []
};
const userWorkspaces = [
...state.userWorkspaces.filter((item) => item.path !== path),
workspace
].sort((a, b) => a.name.localeCompare(b.name));
await storageSet(USER_WORKSPACES_STORAGE_KEY, userWorkspaces);
set({
userWorkspaces,
workspaceDraft: {
name: "",
path: "",
search: ""
},
activeDialog: null
});
setStatus(set, `Added workspace ${name}.`, "success");
},
deleteUserWorkspace: async (path) => {
const state = appStore.getState();
const workspace = state.userWorkspaces.find((item) => item.path === path);
if (!workspace) {
setStatus(set, "Could not find that user-added workspace.", "error");
return;
}
const userWorkspaces = state.userWorkspaces.filter((item) => item.path !== path);
await storageSet(USER_WORKSPACES_STORAGE_KEY, userWorkspaces);
set((current) => ({
userWorkspaces,
promptDraft:
current.promptDraft.workspacePath === path
? {
...current.promptDraft,
workspacePath: getFallbackWorkspacePath({
...current,
userWorkspaces
})
}
: current.promptDraft
}));
setStatus(set, `Deleted workspace ${workspace.name}.`, "success");
},
setIssueDraft: (draft) =>
set((state) => ({
issueDraft: {
...state.issueDraft,
...draft
}
})),
setIssueFiles: (files) =>
set((state) => ({
issueDraft: {
...state.issueDraft,
files: appendFiles(state.issueDraft.files, files)
}
})),
deleteIssueFile: (index) =>
set((state) => ({
issueDraft: {
...state.issueDraft,
files: state.issueDraft.files.filter((_file, fileIndex) => fileIndex !== index)
}
})),
deleteIssueScreenshot: (id) =>
set((state) => ({
issueDraft: {
...state.issueDraft,
screenshots: state.issueDraft.screenshots.filter((screenshot) => screenshot.id !== id)
}
})),
setIssueCommentDraft: (draft) =>
set((state) => ({
issueCommentDraft: {
...state.issueCommentDraft,
...draft
}
})),
setIssueCommentFiles: (files) =>
set((state) => ({
issueCommentDraft: {
...state.issueCommentDraft,
files: appendFiles(state.issueCommentDraft.files, files)
}
})),
deleteIssueCommentFile: (index) =>
set((state) => ({
issueCommentDraft: {
...state.issueCommentDraft,
files: state.issueCommentDraft.files.filter((_file, fileIndex) => fileIndex !== index)
}
})),
deleteIssueCommentScreenshot: (id) =>
set((state) => ({
issueCommentDraft: {
...state.issueCommentDraft,
screenshots: state.issueCommentDraft.screenshots.filter((screenshot) => screenshot.id !== id)
}
})),
setIssueAttachmentFiles: (files) =>
set((state) => ({
issueAttachmentDraft: {
...state.issueAttachmentDraft,
files: appendFiles(state.issueAttachmentDraft.files, files)
}
})),
deleteIssueAttachmentFile: (index) =>
set((state) => ({
issueAttachmentDraft: {
...state.issueAttachmentDraft,
files: state.issueAttachmentDraft.files.filter((_file, fileIndex) => fileIndex !== index)
}
})),
deleteIssueAttachmentScreenshot: (id) =>
set((state) => ({
issueAttachmentDraft: {
...state.issueAttachmentDraft,
screenshots: state.issueAttachmentDraft.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");
},
setPromptDraft: (draft) =>
set((state) => ({
promptDraft: {
...state.promptDraft,
...draft
}
})),
startNewPrompt: () =>
set((state) => ({
promptDraft: {
...EMPTY_PROMPT_DRAFT,
id: crypto.randomUUID(),
workspacePath: getFallbackWorkspacePath(state)
},
editingPromptId: null
})),
editPrompt: (id) =>
set((state) => {
const prompt = state.prompts.find((item) => item.id === id);
return prompt
? {
promptDraft: { ...prompt },
editingPromptId: id
}
: {};
}),
savePrompt: async () => {
const state = appStore.getState();
const draft = normalizePrompt(
{
...state.promptDraft,
id: state.editingPromptId ?? state.promptDraft.id ?? crypto.randomUUID()
},
getFallbackWorkspacePath(state)
);
if (!draft.name.trim() || !draft.workspacePath.trim() || !draft.content.trim()) {
setStatus(set, "Prompt name, workspace, and content are required.", "error");
return;
}
const prompts = state.editingPromptId
? state.prompts.map((prompt) => (prompt.id === state.editingPromptId ? draft : prompt))
: [...state.prompts, draft];
const normalizedPrompts = normalizePrompts(prompts, getFallbackWorkspacePath(state));
await storageSet(PROMPTS_STORAGE_KEY, normalizedPrompts);
set({
prompts: normalizedPrompts,
promptDraft: { ...draft },
editingPromptId: draft.id
});
setStatus(set, `Saved ${draft.name}.`, "success");
},
deletePrompt: async (id) => {
const state = appStore.getState();
const prompt = state.prompts.find((item) => item.id === id);
if (!prompt) {
setStatus(set, "Could not find that prompt.", "error");
return;
}
const prompts = state.prompts.filter((item) => item.id !== id);
await storageSet(PROMPTS_STORAGE_KEY, prompts);
set((current) => ({
prompts,
issueDraft: {
...current.issueDraft,
promptId: current.issueDraft.promptId === id ? "" : current.issueDraft.promptId
},
promptDraft: current.editingPromptId === id
? {
...EMPTY_PROMPT_DRAFT,
workspacePath: getFallbackWorkspacePath(current)
}
: current.promptDraft,
editingPromptId: current.editingPromptId === id ? null : current.editingPromptId
}));
setStatus(set, `Deleted ${prompt.name}.`, "success");
},
setIssuePrompt: (promptId) =>
set((state) => {
const prompt = promptId ? getPromptById(promptId, state.prompts) : null;
return {
issueDraft: {
...state.issueDraft,
promptId: prompt?.id ?? "",
content: prompt ? prompt.content : state.issueDraft.content
}
};
}),
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
: preview.target === "comment"
? state.issueCommentDraft.screenshots
: state.issueAttachmentDraft.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
}
: preview.target === "comment"
? {
issueCommentDraft: {
...current.issueCommentDraft,
screenshots: current.issueCommentDraft.screenshots.map((item) =>
item.id === preview.id ? cropped : item
)
},
screenshotPreview: null
}
: {
issueAttachmentDraft: {
...current.issueAttachmentDraft,
screenshots: current.issueAttachmentDraft.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 if (preview.target === "comment") {
appStore.getState().deleteIssueCommentScreenshot(preview.id);
} else {
appStore.getState().deleteIssueAttachmentScreenshot(preview.id);
}
set({ screenshotPreview: null });
},
setOpenIssuesLimit: (limitToCurrentRepo) =>
set({
openIssuesLimitToCurrentRepo: limitToCurrentRepo,
openIssuesScopeKey: "",
openIssues: [],
...emptyIssueDrafts()
}),
setOpenIssuesStatusFilters: (statusFilters) =>
set({
openIssuesStatusFilters: statusFilters,
openIssuesScopeKey: "",
openIssues: [],
...emptyIssueDrafts()
}),
setOpenIssuesLabelFilter: (labelFilter) =>
set({
openIssuesLabelFilter: labelFilter,
openIssuesScopeKey: "",
openIssues: [],
...emptyIssueDrafts()
}),
clearSelectedIssue: () => set(emptyIssueDrafts()),
selectIssue: (issueNumber, repoFullName) =>
set((state) => {
const selectedRepoFullName = repoFullName ?? state.selectedIssueRepoFullName ?? getTargetRepo(state)?.fullName ?? "";
if (state.selectedIssueNumber === issueNumber && state.selectedIssueRepoFullName === selectedRepoFullName) {
return emptyIssueDrafts();
}
return {
selectedIssueNumber: issueNumber,
selectedIssueRepoFullName: selectedRepoFullName,
selectedIssueDetail:
state.selectedIssueDetailNumber === issueNumber &&
(!repoFullName || state.selectedIssueRepoFullName === repoFullName)
? state.selectedIssueDetail
: null,
selectedIssueDetailNumber:
state.selectedIssueDetailNumber === issueNumber &&
(!repoFullName || state.selectedIssueRepoFullName === repoFullName)
? state.selectedIssueDetailNumber
: null,
selectedIssueEditDraft:
state.selectedIssueDetailNumber === issueNumber &&
(!repoFullName || state.selectedIssueRepoFullName === repoFullName) &&
state.selectedIssueDetail
? {
subject: state.selectedIssueDetail.issue.title,
content: state.selectedIssueDetail.issue.body,
labels: normalizeIssueLabels(state.selectedIssueDetail.issue.labels)
}
: {
subject: "",
content: "",
labels: []
},
issueCommentDraft: {
content: "",
screenshots: [],
files: []
},
issueAttachmentDraft: {
screenshots: [],
files: []
}
};
}),
loadOpenIssues: async (force = false) => {
const state = appStore.getState();
const scopeKey = getOpenIssuesScopeKey(state);
const giteaState = getOpenIssuesGiteaState(state.openIssuesStatusFilters);
if (!force && state.openIssuesScopeKey === scopeKey) {
return;
}
set({
isLoadingTargetIssues: true,
openIssuesScopeKey: scopeKey
});
try {
if (!giteaState) {
set({
openIssues: [],
openIssuesScopeKey: scopeKey,
...emptyIssueDrafts()
});
return;
}
const targets = state.openIssuesLimitToCurrentRepo
? [getTargetRepo(state)].filter((target): target is RepoTarget => Boolean(target))
: state.repos
.map((repo) => getRepoTargetByFullName(state, repo.fullName))
.filter((target): target is RepoTarget => Boolean(target));
if (!targets.length) {
set({
openIssues: [],
...emptyIssueDrafts()
});
return;
}
const issueGroups = await Promise.all(
targets.map(async (target) =>
(
await getGiteaIssues(target.owner, target.repo, {
limit: 25,
order: "desc",
sort: "created",
state: giteaState
})
).map((issue) => toOpenIssue(issue, target))
)
);
const openIssues = issueGroups
.flat()
.filter((issue) => issueHasLabel(issue, state.openIssuesLabelFilter))
.sort((a, b) => {
const aTime = a.createdAt ? Date.parse(a.createdAt) : 0;
const bTime = b.createdAt ? Date.parse(b.createdAt) : 0;
return bTime - aTime || a.repoFullName.localeCompare(b.repoFullName) || b.number - a.number;
})
.slice(0, state.openIssuesLimitToCurrentRepo ? 25 : 100);
set((current) => ({
openIssues,
openIssuesScopeKey: scopeKey,
selectedIssueNumber:
current.selectedIssueNumber &&
openIssues.some(
(issue) =>
issue.number === current.selectedIssueNumber &&
issue.repoFullName === current.selectedIssueRepoFullName
)
? current.selectedIssueNumber
: null,
selectedIssueRepoFullName:
current.selectedIssueNumber &&
openIssues.some(
(issue) =>
issue.number === current.selectedIssueNumber &&
issue.repoFullName === current.selectedIssueRepoFullName
)
? current.selectedIssueRepoFullName
: "",
selectedIssueDetail:
current.selectedIssueNumber &&
openIssues.some(
(issue) =>
issue.number === current.selectedIssueNumber &&
issue.repoFullName === current.selectedIssueRepoFullName
)
? current.selectedIssueDetail
: null,
selectedIssueDetailNumber:
current.selectedIssueNumber &&
openIssues.some(
(issue) =>
issue.number === current.selectedIssueNumber &&
issue.repoFullName === current.selectedIssueRepoFullName
)
? current.selectedIssueDetailNumber
: null,
selectedIssueEditDraft:
current.selectedIssueNumber &&
openIssues.some(
(issue) =>
issue.number === current.selectedIssueNumber &&
issue.repoFullName === current.selectedIssueRepoFullName
)
? current.selectedIssueEditDraft
: {
subject: "",
content: "",
labels: []
}
}));
} catch (error) {
const message = error instanceof Error ? error.message : "Could not load open Gitea issues.";
setStatus(set, message, "error");
} finally {
set({ isLoadingTargetIssues: false });
}
},
loadTargetIssues: async (force = false) => {
const state = appStore.getState();
const target = getTargetRepo(state);
if (!target) {
set({
targetIssues: [],
issueListRepoFullName: "",
selectedIssueNumber: null,
selectedIssueDetail: null,
selectedIssueDetailNumber: null
});
return;
}
if (!force && state.issueListRepoFullName === target.fullName && state.targetIssues.length > 0) {
return;
}
set({
isLoadingTargetIssues: true,
issueListRepoFullName: target.fullName
});
try {
const targetIssues = await getGiteaIssues(target.owner, target.repo);
set((current) => ({
targetIssues,
issueListRepoFullName: target.fullName,
selectedIssueNumber:
current.selectedIssueNumber && targetIssues.some((issue) => issue.number === current.selectedIssueNumber)
? current.selectedIssueNumber
: null,
selectedIssueDetail:
current.selectedIssueNumber && targetIssues.some((issue) => issue.number === current.selectedIssueNumber)
? current.selectedIssueDetail
: null,
selectedIssueDetailNumber:
current.selectedIssueNumber && targetIssues.some((issue) => issue.number === current.selectedIssueNumber)
? current.selectedIssueDetailNumber
: null
}));
} catch (error) {
const message = error instanceof Error ? error.message : "Could not load Gitea issues.";
setStatus(set, message, "error");
} finally {
set({ isLoadingTargetIssues: false });
}
},
loadSelectedIssueDetail: async (force = false) => {
const state = appStore.getState();
const target = getIssueTarget(state);
const issueNumber = state.selectedIssueNumber;
if (!target || !issueNumber) {
set({
selectedIssueDetail: null,
selectedIssueDetailNumber: null
});
return;
}
if (!force && state.selectedIssueDetailNumber === issueNumber && state.selectedIssueDetail) {
return;
}
set({
isLoadingIssueDetail: true,
selectedIssueDetailNumber: issueNumber
});
try {
const selectedIssueDetail = await getGiteaIssueDetail(target.owner, target.repo, issueNumber);
set({
selectedIssueDetail,
selectedIssueDetailNumber: issueNumber,
selectedIssueEditDraft: {
subject: selectedIssueDetail.issue.title,
content: selectedIssueDetail.issue.body,
labels: normalizeIssueLabels(selectedIssueDetail.issue.labels)
}
});
} catch (error) {
const message = error instanceof Error ? error.message : "Could not load issue details.";
setStatus(set, message, "error");
} finally {
set({ isLoadingIssueDetail: false });
}
},
setSelectedIssueEditDraft: (draft) =>
set((state) => ({
selectedIssueEditDraft: {
...state.selectedIssueEditDraft,
...draft
}
})),
saveSelectedIssueEdits: async () => {
const state = appStore.getState();
const issueNumber = state.selectedIssueNumber;
const repoFullName = state.selectedIssueRepoFullName;
const target = repoFullName ? getRepoTargetByFullName(state, repoFullName) : null;
const subject = state.selectedIssueEditDraft.subject.trim();
if (!target || !issueNumber) {
setStatus(set, "Select an issue from Issues before editing it.", "error");
return;
}
if (!subject) {
setStatus(set, "Issue subject is required.", "error");
return;
}
set({ isUpdatingIssue: true });
setStatus(set, `Saving issue #${issueNumber}...`, "neutral", { autoClear: false });
try {
const selectedLabels = state.selectedIssueEditDraft.labels;
const ensuredLabels = await ensureIssueLabels(target.owner, target.repo, selectedLabels);
const updatedIssue = await updateGiteaIssue(target.owner, target.repo, issueNumber, {
title: subject,
body: state.selectedIssueEditDraft.content
});
const savedLabels = await setGiteaIssueLabels(target.owner, target.repo, issueNumber, ensuredLabels.names);
const normalizedSavedLabels = normalizeIssueLabels(
savedLabels.length || !selectedLabels.length ? savedLabels : ensuredLabels.names
);
const issueWithLabels = {
...updatedIssue,
labels: normalizedSavedLabels
};
const updatedOpenIssue = toOpenIssue(issueWithLabels, target);
set((current) => ({
openIssues: current.openIssues.map((issue) =>
issue.number === issueNumber && issue.repoFullName === target.fullName ? updatedOpenIssue : issue
),
selectedIssueRepoFullName: target.fullName,
selectedIssueDetail:
current.selectedIssueDetail && current.selectedIssueNumber === issueNumber
? {
...current.selectedIssueDetail,
issue: {
...current.selectedIssueDetail.issue,
...issueWithLabels
}
}
: current.selectedIssueDetail,
selectedIssueEditDraft: {
subject: issueWithLabels.title,
content: issueWithLabels.body,
labels: normalizeIssueLabels(issueWithLabels.labels)
}
}));
await appStore.getState().loadSelectedIssueDetail(true);
setStatus(set, `Saved issue #${issueNumber}.`, "success");
} catch (error) {
const message = error instanceof Error ? error.message : "Could not save the issue.";
setStatus(set, message, "error");
} finally {
set({ isUpdatingIssue: false });
}
},
deleteIssue: async (issueNumber, repoFullName) => {
const target = getIssueTarget(appStore.getState(), repoFullName);
if (!target) {
setStatus(set, "Open a known URL with a linked Gitea repo before deleting an issue.", "error");
return;
}
set({ isUpdatingIssue: true });
setStatus(set, `Deleting issue #${issueNumber}...`, "neutral", { autoClear: false });
try {
await deleteGiteaIssue(target.owner, target.repo, issueNumber);
set((state) => ({
targetIssues: state.targetIssues.filter((issue) => issue.number !== issueNumber),
openIssues: state.openIssues.filter(
(issue) => issue.number !== issueNumber || issue.repoFullName !== target.fullName
),
selectedIssueNumber:
state.selectedIssueNumber === issueNumber && state.selectedIssueRepoFullName === target.fullName
? null
: state.selectedIssueNumber,
selectedIssueRepoFullName:
state.selectedIssueNumber === issueNumber && state.selectedIssueRepoFullName === target.fullName
? ""
: state.selectedIssueRepoFullName,
selectedIssueDetail:
state.selectedIssueNumber === issueNumber && state.selectedIssueRepoFullName === target.fullName
? null
: state.selectedIssueDetail,
selectedIssueDetailNumber:
state.selectedIssueNumber === issueNumber && state.selectedIssueRepoFullName === target.fullName
? null
: state.selectedIssueDetailNumber,
selectedIssueEditDraft:
state.selectedIssueNumber === issueNumber && state.selectedIssueRepoFullName === target.fullName
? {
subject: "",
content: "",
labels: []
}
: state.selectedIssueEditDraft,
issueCommentDraft:
state.selectedIssueNumber === issueNumber && state.selectedIssueRepoFullName === target.fullName
? {
content: "",
screenshots: [],
files: []
}
: state.issueCommentDraft,
issueAttachmentDraft:
state.selectedIssueNumber === issueNumber && state.selectedIssueRepoFullName === target.fullName
? {
screenshots: [],
files: []
}
: state.issueAttachmentDraft
}));
await appStore.getState().loadOpenIssues(true);
await appStore.getState().loadTargetIssues(true);
setStatus(set, `Deleted issue #${issueNumber}.`, "success");
} catch (error) {
const message = error instanceof Error ? error.message : "Could not delete the Gitea issue.";
setStatus(set, message, "error");
} finally {
set({ isUpdatingIssue: false });
}
},
deleteSelectedIssueAttachment: async (attachmentId) => {
const state = appStore.getState();
const target = getIssueTarget(state);
const issueNumber = state.selectedIssueNumber;
if (!target || !issueNumber) {
setStatus(set, "Select an issue before deleting an attachment.", "error");
return;
}
set({ isUpdatingIssue: true });
setStatus(set, `Deleting attachment from issue #${issueNumber}...`, "neutral", { autoClear: false });
try {
await deleteGiteaIssueAttachment(target.owner, target.repo, issueNumber, attachmentId);
await appStore.getState().loadSelectedIssueDetail(true);
setStatus(set, "Deleted attachment.", "success");
} catch (error) {
const message = error instanceof Error ? error.message : "Could not delete the attachment.";
setStatus(set, message, "error");
} finally {
set({ isUpdatingIssue: false });
}
},
captureScreenshot: async () => {
set({ isCapturingScreenshot: true });
setStatus(set, "Capturing screenshot...", "neutral", { autoClear: false });
try {
const { dataUrl, blob } = await captureVisibleTab();
const screenshot = createScreenshotAttachment(dataUrl, blob);
set((state) => ({
issueDraft: {
...state.issueDraft,
screenshots: [...state.issueDraft.screenshots, screenshot]
}
}));
setStatus(set, `Captured ${screenshot.name}.`, "success");
} catch (error) {
const message = error instanceof Error ? error.message : "Could not capture a screenshot.";
setStatus(set, message, "error");
} finally {
set({ isCapturingScreenshot: false });
}
},
captureIssueCommentScreenshot: async () => {
set({ isCapturingIssueCommentScreenshot: true });
setStatus(set, "Capturing screenshot...", "neutral", { autoClear: false });
try {
const { dataUrl, blob } = await captureVisibleTab();
const screenshot = createScreenshotAttachment(dataUrl, blob);
set((state) => ({
issueCommentDraft: {
...state.issueCommentDraft,
screenshots: [...state.issueCommentDraft.screenshots, screenshot]
}
}));
setStatus(set, `Captured ${screenshot.name}.`, "success");
} catch (error) {
const message = error instanceof Error ? error.message : "Could not capture a screenshot.";
setStatus(set, message, "error");
} finally {
set({ isCapturingIssueCommentScreenshot: false });
}
},
captureIssueAttachmentScreenshot: async () => {
set({ isCapturingIssueAttachmentScreenshot: true });
setStatus(set, "Capturing screenshot...", "neutral", { autoClear: false });
try {
const { dataUrl, blob } = await captureVisibleTab();
const screenshot = createScreenshotAttachment(dataUrl, blob);
set((state) => ({
issueAttachmentDraft: {
...state.issueAttachmentDraft,
screenshots: [...state.issueAttachmentDraft.screenshots, screenshot]
}
}));
setStatus(set, `Captured ${screenshot.name}.`, "success");
} catch (error) {
const message = error instanceof Error ? error.message : "Could not capture a screenshot.";
setStatus(set, message, "error");
} finally {
set({ isCapturingIssueAttachmentScreenshot: false });
}
},
uploadSelectedIssueAttachments: async () => {
const state = appStore.getState();
const target = getIssueTarget(state);
const issueNumber = state.selectedIssueNumber;
const uploads = [
...state.issueAttachmentDraft.screenshots.map((screenshot) => ({
name: screenshot.name,
blob: screenshot.blob
})),
...state.issueAttachmentDraft.files.map((file) => ({
name: file.name,
blob: file
}))
];
if (!target || !issueNumber) {
setStatus(set, "Select an issue before uploading attachments.", "error");
return;
}
if (!uploads.length) {
setStatus(set, "Add a screenshot or file before uploading.", "error");
return;
}
set({ isUploadingIssueAttachments: true });
setStatus(set, `Uploading attachments to issue #${issueNumber}...`, "neutral", { autoClear: false });
try {
for (const upload of uploads) {
await uploadGiteaIssueAttachment(target.owner, target.repo, issueNumber, upload.blob, upload.name);
}
set({
issueAttachmentDraft: {
screenshots: [],
files: []
}
});
await appStore.getState().loadSelectedIssueDetail(true);
setStatus(set, `Uploaded ${uploads.length} attachment${uploads.length === 1 ? "" : "s"}.`, "success");
} catch (error) {
const message = error instanceof Error ? error.message : "Could not upload issue attachments.";
setStatus(set, message, "error");
} finally {
set({ isUploadingIssueAttachments: false });
}
},
createIssueComment: async () => {
const state = appStore.getState();
const target = getIssueTarget(state);
const selectedIssueNumber = state.selectedIssueNumber;
const hasAttachments =
state.issueCommentDraft.screenshots.length > 0 || state.issueCommentDraft.files.length > 0;
if (!target || !selectedIssueNumber) {
setStatus(set, "Select an issue before commenting.", "error");
return;
}
if (!state.issueCommentDraft.content.trim() && !hasAttachments) {
setStatus(set, "Add a comment or attachment before submitting.", "error");
return;
}
set({ isCreatingIssueComment: true });
setStatus(set, `Adding comment to issue #${selectedIssueNumber}...`, "neutral", { autoClear: false });
try {
const uploads = [
...state.issueCommentDraft.screenshots.map((screenshot) => ({
name: screenshot.name,
blob: screenshot.blob
})),
...state.issueCommentDraft.files.map((file) => ({
name: file.name,
blob: file
}))
];
const attachments: GiteaIssueAttachment[] = [];
for (const upload of uploads) {
attachments.push(
await uploadGiteaIssueAttachment(target.owner, target.repo, selectedIssueNumber, upload.blob, upload.name)
);
}
await createGiteaIssueComment(
target.owner,
target.repo,
selectedIssueNumber,
buildIssueCommentBody(state, target.knownUrl, attachments)
);
set({
issueCommentDraft: {
content: "",
screenshots: [],
files: []
}
});
await appStore.getState().loadOpenIssues(true);
await appStore.getState().loadTargetIssues(true);
await appStore.getState().loadSelectedIssueDetail(true);
setStatus(set, `Commented on issue #${selectedIssueNumber}.`, "success");
} catch (error) {
const message = error instanceof Error ? error.message : "Could not comment on the Gitea issue.";
setStatus(set, message, "error");
} finally {
set({ isCreatingIssueComment: false });
}
},
createIssue: async () => {
const state = appStore.getState();
const target = getTargetRepo(state);
const subject = state.issueDraft.subject.trim();
if (!target) {
setStatus(set, "Open a known URL with a linked Gitea repo before creating an issue.", "error");
return;
}
if (!target.knownUrl) {
setStatus(set, "Open a known URL before creating an issue.", "error");
return;
}
if (!subject) {
setStatus(set, "Add an issue subject before creating it.", "error");
return;
}
set({ isCreatingIssue: true });
setStatus(set, "Creating Gitea issue...", "neutral", { autoClear: false });
try {
const { ids: labelIds } = await ensureIssueLabels(target.owner, target.repo, state.issueDraft.labels);
const issue = await createGiteaIssue(
target.owner,
target.repo,
subject,
buildIssueBody(state, target.knownUrl),
labelIds
);
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
})),
...state.issueDraft.files.map((file) => ({
name: file.name,
blob: file
}))
];
for (const upload of uploads) {
await uploadGiteaIssueAttachment(target.owner, target.repo, issue.number, upload.blob, upload.name);
}
set({
issueDraft: {
subject: "",
content: "",
promptId: "",
agentId: appStore.getState().agents[0]?.id ?? AVAILABLE_AGENTS[0].id,
labels: ["Change Request"],
screenshots: [],
files: []
},
selectedIssueNumber: issue.number,
selectedIssueRepoFullName: target.fullName,
activeView: "open-issues"
});
await appStore.getState().loadOpenIssues(true);
await appStore.getState().loadTargetIssues(true);
await appStore.getState().loadSelectedIssueDetail(true);
setStatus(set, `Created issue #${issue.number} in ${target.fullName}.`, "success");
} catch (error) {
const message = error instanceof Error ? error.message : "Could not create the Gitea issue.";
setStatus(set, message, "error");
} finally {
set({ isCreatingIssue: false });
}
},
refreshActiveTab: async () => {
const activeTabUrl = await getActiveTabUrl();
const activeBaseUrl = getBaseUrl(activeTabUrl);
const currentBaseUrl = get().activeBaseUrl;
set({
activeTabUrl,
activeBaseUrl,
addUrlDraft:
activeBaseUrl !== currentBaseUrl
? { mode: null, selectedValue: "" }
: get().addUrlDraft,
issueDraft:
activeBaseUrl !== currentBaseUrl
? {
...get().issueDraft,
promptId: ""
}
: get().issueDraft,
targetIssues:
activeBaseUrl !== currentBaseUrl
? []
: get().targetIssues,
issueListRepoFullName:
activeBaseUrl !== currentBaseUrl
? ""
: get().issueListRepoFullName,
openIssues:
activeBaseUrl !== currentBaseUrl && get().openIssuesLimitToCurrentRepo
? []
: get().openIssues,
openIssuesScopeKey:
activeBaseUrl !== currentBaseUrl && get().openIssuesLimitToCurrentRepo
? ""
: get().openIssuesScopeKey,
selectedIssueNumber:
activeBaseUrl !== currentBaseUrl && get().openIssuesLimitToCurrentRepo
? null
: get().selectedIssueNumber,
selectedIssueRepoFullName:
activeBaseUrl !== currentBaseUrl && get().openIssuesLimitToCurrentRepo
? ""
: get().selectedIssueRepoFullName,
selectedIssueDetail:
activeBaseUrl !== currentBaseUrl && get().openIssuesLimitToCurrentRepo
? null
: get().selectedIssueDetail,
selectedIssueDetailNumber:
activeBaseUrl !== currentBaseUrl && get().openIssuesLimitToCurrentRepo
? null
: get().selectedIssueDetailNumber,
selectedIssueEditDraft:
activeBaseUrl !== currentBaseUrl && get().openIssuesLimitToCurrentRepo
? {
subject: "",
content: "",
labels: []
}
: get().selectedIssueEditDraft,
promptDraft:
activeBaseUrl !== currentBaseUrl && !get().editingPromptId
? {
...get().promptDraft,
workspacePath: findKnownUrlByBase(get(), activeBaseUrl)?.workspacePath || get().promptDraft.workspacePath
}
: get().promptDraft
});
},
loadData: async () => {
set({
isLoadingData: true
});
setStatus(set, "Loading workspace and Gitea context...", "neutral", { autoClear: false });
try {
const [localContext, repos, userUrls, userWorkspaces, deletedUrlBases, agents, prompts, activeTabUrl] = await Promise.all([
loadLocalContext(),
getReposOrderedByLastCommit(),
storageGet<KnownUrl[]>(USER_URLS_STORAGE_KEY, []),
storageGet<LocalWorkspace[]>(USER_WORKSPACES_STORAGE_KEY, []),
storageGet<string[]>(DELETED_URL_BASES_STORAGE_KEY, []),
storageGet<AgentProfile[]>(AGENTS_STORAGE_KEY, AVAILABLE_AGENTS),
storageGet<WorkspacePrompt[]>(PROMPTS_STORAGE_KEY, []),
getActiveTabUrl()
]);
const activeBaseUrl = getBaseUrl(activeTabUrl);
const normalizedAgents = normalizeAgents(agents);
const normalizedUserWorkspaces = userWorkspaces
.map((workspace) => ({
name: workspace.name?.trim() || workspace.path.split("/").filter(Boolean).slice(-1)[0] || "workspace",
path: workspace.path?.trim().replace(/\/+$/, "") || "",
linkedRepos: workspace.linkedRepos ?? []
}))
.filter((workspace) => workspace.path);
const activeKnownUrl = findKnownUrlByBase(
{
...get(),
localContext,
userWorkspaces: normalizedUserWorkspaces,
userUrls,
deletedUrlBases,
activeBaseUrl
},
activeBaseUrl
);
const fallbackWorkspacePath = activeKnownUrl?.workspacePath ?? localContext.workspaces[0]?.path ?? normalizedUserWorkspaces[0]?.path ?? "";
const normalizedPrompts = normalizePrompts(prompts, fallbackWorkspacePath);
set({
localContext,
repos,
userWorkspaces: normalizedUserWorkspaces,
userUrls: userUrls.filter((url) => !deletedUrlBases.includes(getBaseUrl(url.url))),
deletedUrlBases,
agents: normalizedAgents,
prompts: normalizedPrompts,
issueDraft: {
...get().issueDraft,
agentId: normalizedAgents.some((agent) => agent.id === get().issueDraft.agentId)
? get().issueDraft.agentId
: normalizedAgents[0].id,
promptId: normalizedPrompts.some((prompt) => prompt.id === get().issueDraft.promptId)
? get().issueDraft.promptId
: ""
},
agentDraft: normalizedAgents.find((agent) => agent.id === get().editingAgentId) ?? normalizedAgents[0],
editingAgentId:
normalizedAgents.find((agent) => agent.id === get().editingAgentId)?.id ?? normalizedAgents[0].id,
promptDraft: normalizedPrompts.find((prompt) => prompt.id === get().editingPromptId) ?? {
...EMPTY_PROMPT_DRAFT,
workspacePath: fallbackWorkspacePath
},
editingPromptId: normalizedPrompts.find((prompt) => prompt.id === get().editingPromptId)?.id ?? null,
activeTabUrl,
activeBaseUrl
});
setStatus(set, `Loaded ${repos.length} repos and ${localContext.workspaces.length} workspaces.`, "success");
} catch (error) {
const message = error instanceof Error ? error.message : "Could not load app data.";
setStatus(set, message, "error");
} finally {
set({ isLoadingData: false });
}
},
saveCurrentUrlLink: async () => {
const state = appStore.getState();
const context = state.localContext;
if (!context || !state.activeBaseUrl) {
setStatus(set, "No active URL is available to link.", "error");
return;
}
const link = inferUrlLink(context, state.activeBaseUrl, state.addUrlDraft);
if (!link) {
setStatus(set, "Pick either a workspace or a Gitea repo before adding this URL.", "error");
return;
}
const userUrls = [
...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",
activeDialog: null,
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,
isTestingConnection: true
});
setStatus(set, "Testing connection...", "neutral", { autoClear: false });
try {
const user = await getCurrentGiteaUser();
set({
connectedUser: user
});
setStatus(set, `Connected as ${user.full_name || user.login}.`, "success");
} catch (error) {
const message = error instanceof Error ? error.message : "Connection failed.";
set({
connectedUser: null
});
setStatus(set, message, "error");
} finally {
set({ isTestingConnection: false });
}
}
}));