From 6106adbc6fef7464de7285e4b67ad8dd4bafd5eb Mon Sep 17 00:00:00 2001 From: Peisong Xiao Date: Sun, 18 Jan 2026 04:45:32 -0500 Subject: [PATCH] gemini handoff to codex --- sitecompanion/background.js | 46 +- sitecompanion/content.js | 247 +++++------ sitecompanion/manifest.json | 12 +- sitecompanion/popup.css | 58 ++- sitecompanion/popup.html | 40 +- sitecompanion/popup.js | 226 ++++++++-- sitecompanion/settings.css | 130 ++++-- sitecompanion/settings.html | 264 +++++++----- sitecompanion/settings.js | 817 ++++++++++++++++++++++++++++++------ 9 files changed, 1389 insertions(+), 451 deletions(-) diff --git a/sitecompanion/background.js b/sitecompanion/background.js index 36bacc9..f6a57fe 100644 --- a/sitecompanion/background.js +++ b/sitecompanion/background.js @@ -28,9 +28,9 @@ const DEFAULT_SETTINGS = { model: "gpt-4o-mini", systemPrompt: "You are a precise, honest assistant. Be concise and avoid inventing details, be critical about evaluations. You should put in a small summary of all the sections at the end. You should answer in no longer than 3 sections including the summary. And remember to bold or italicize key points.", - resume: "", tasks: DEFAULT_TASKS, - theme: "system" + theme: "system", + workspaces: [] }; const OUTPUT_STORAGE_KEY = "lastOutput"; @@ -54,7 +54,7 @@ function resetAbort() { function openKeepalive(tabId) { if (!tabId || keepalivePort) return; try { - keepalivePort = chrome.tabs.connect(tabId, { name: "wwcompanion-keepalive" }); + keepalivePort = chrome.tabs.connect(tabId, { name: "sitecompanion-keepalive" }); keepalivePort.onDisconnect.addListener(() => { keepalivePort = null; }); @@ -253,20 +253,17 @@ chrome.runtime.onInstalled.addListener(async () => { { id, name: "Default", - text: stored.resume || "", - type: "Resume" + text: stored.resume || "" } ]; } else { const normalizedProfiles = stored.profiles.map((profile) => ({ ...profile, - text: profile.text ?? "", - type: profile.type === "Profile" ? "Profile" : "Resume" + text: profile.text ?? "" })); const needsProfileUpdate = normalizedProfiles.some( (profile, index) => - (profile.text || "") !== (stored.profiles[index]?.text || "") || - (profile.type || "Resume") !== (stored.profiles[index]?.type || "Resume") + (profile.text || "") !== (stored.profiles[index]?.text || "") ); if (needsProfileUpdate) { updates.profiles = normalizedProfiles; @@ -364,17 +361,16 @@ chrome.runtime.onMessage.addListener((message) => { } }); -function buildUserMessage(resume, resumeType, task, posting) { - const header = resumeType === "Profile" ? "=== PROFILE ===" : "=== RESUME ==="; +function buildUserMessage(profileText, taskText, siteText) { return [ - header, - resume || "", + "=== Profile ===", + profileText || "", "", - "=== TASK ===", - task || "", + "=== Task ===", + taskText || "", "", - "=== JOB POSTING ===", - posting || "" + "=== Site Text ===", + siteText || "" ].join("\n"); } @@ -406,10 +402,9 @@ async function handleAnalysisRequest(port, payload, signal) { apiKeyPrefix, model, systemPrompt, - resume, - resumeType, + profileText, taskText, - postingText, + siteText, tabId } = payload || {}; @@ -444,21 +439,20 @@ async function handleAnalysisRequest(port, payload, signal) { } } - if (!postingText) { - safePost(port, { type: "ERROR", message: "No job posting text provided." }); + if (!siteText) { + safePost(port, { type: "ERROR", message: "No site text provided." }); return; } if (!taskText) { - safePost(port, { type: "ERROR", message: "No task prompt selected." }); + safePost(port, { type: "ERROR", message: "No task selected." }); return; } const userMessage = buildUserMessage( - resume, - resumeType, + profileText, taskText, - postingText + siteText ); await chrome.storage.local.set({ [OUTPUT_STORAGE_KEY]: "" }); diff --git a/sitecompanion/content.js b/sitecompanion/content.js index 0a4d308..350a6cd 100644 --- a/sitecompanion/content.js +++ b/sitecompanion/content.js @@ -1,140 +1,143 @@ -const HEADER_LINES = new Set([ - "OVERVIEW", - "PRE-SCREENING", - "WORK TERM RATINGS", - "JOB POSTING INFORMATION", - "APPLICATION INFORMATION", - "COMPANY INFORMATION", - "SERVICE TEAM" -]); +function findMinimumScope(text) { + if (!text) return null; + const normalized = text.trim(); + if (!normalized) return null; -const ACTION_BAR_SELECTOR = "nav.floating--action-bar"; -const INJECTED_ATTR = "data-wwcompanion-default-task"; -const DEFAULT_TASK_LABEL = "Default WWCompanion Task"; - -function isJobPostingOpen() { - return document.getElementsByClassName("modal__content").length > 0; -} - -function sanitizePostingText(text) { - let cleaned = text.replaceAll("fiber_manual_record", ""); - const lines = cleaned.split(/\r?\n/); - const filtered = lines.filter((line) => { - const trimmed = line.trim(); - if (!trimmed) return true; - return !HEADER_LINES.has(trimmed.toUpperCase()); + const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT, { + acceptNode: (node) => { + if (node.innerText.includes(normalized)) { + return NodeFilter.FILTER_ACCEPT; + } + return NodeFilter.FILTER_REJECT; + } }); - cleaned = filtered.join("\n"); - cleaned = cleaned.replace(/[ \t]+/g, " "); - cleaned = cleaned.replace(/\n{3,}/g, "\n\n"); - return cleaned.trim(); -} - -function buildDefaultTaskButton(templateButton) { - const button = document.createElement("button"); - button.type = "button"; - button.textContent = DEFAULT_TASK_LABEL; - button.className = templateButton.className; - button.setAttribute(INJECTED_ATTR, "true"); - button.setAttribute("aria-label", DEFAULT_TASK_LABEL); - button.addEventListener("click", () => { - document.dispatchEvent(new CustomEvent("WWCOMPANION_RUN_DEFAULT_TASK")); - chrome.runtime.sendMessage({ type: "RUN_DEFAULT_TASK" }); - }); - return button; -} - -function getActionBars() { - return [...document.querySelectorAll(ACTION_BAR_SELECTOR)]; -} - -function getActionBarButtonCount(bar) { - return bar.querySelectorAll(`button:not([${INJECTED_ATTR}])`).length; -} - -function selectTargetActionBar(bars) { - if (!bars.length) return null; - let best = bars[0]; - let bestCount = getActionBarButtonCount(best); - for (const bar of bars.slice(1)) { - const count = getActionBarButtonCount(bar); - if (count > bestCount) { - best = bar; - bestCount = count; - } - } - return best; -} - -function ensureDefaultTaskButton() { - const bars = getActionBars(); - if (!bars.length) return; - - if (!isJobPostingOpen()) { - for (const bar of bars) { - const injected = bar.querySelector(`[${INJECTED_ATTR}]`); - if (injected) injected.remove(); - } - return; + let deepest = null; + let node = walker.nextNode(); + while (node) { + deepest = node; + node = walker.nextNode(); } - const toolbar = selectTargetActionBar(bars); - if (!toolbar) return; + return deepest; +} - for (const bar of bars) { - if (bar === toolbar) continue; - const injected = bar.querySelector(`[${INJECTED_ATTR}]`); - if (injected) injected.remove(); +function createToolbar(presets, position = "bottom-right") { + let toolbar = document.getElementById("sitecompanion-toolbar"); + if (toolbar) toolbar.remove(); + + toolbar = document.createElement("div"); + toolbar.id = "sitecompanion-toolbar"; + + let posStyle = ""; + switch (position) { + case "top-left": + posStyle = "top: 20px; left: 20px;"; + break; + case "top-right": + posStyle = "top: 20px; right: 20px;"; + break; + case "bottom-left": + posStyle = "bottom: 20px; left: 20px;"; + break; + case "bottom-center": + posStyle = "bottom: 20px; left: 50%; transform: translateX(-50%);"; + break; + case "bottom-right": + default: + posStyle = "bottom: 20px; right: 20px;"; + break; } - const existing = toolbar.querySelector(`[${INJECTED_ATTR}]`); - if (existing) return; + toolbar.style.cssText = ` + position: fixed; + ${posStyle} + background: #fff7ec; + border: 1px solid #e4d6c5; + border-radius: 12px; + padding: 8px; + box-shadow: 0 8px 24px rgba(0,0,0,0.15); + z-index: 999999; + display: flex; + gap: 8px; + font-family: system-ui, sans-serif; + `; - const templateButton = toolbar.querySelector("button"); - if (!templateButton) return; - - const button = buildDefaultTaskButton(templateButton); - const firstChild = toolbar.firstElementChild; - if (firstChild) { - toolbar.insertBefore(button, firstChild); + if (!presets || !presets.length) { + const label = document.createElement("span"); + label.textContent = "SiteCompanion"; + label.style.fontSize = "12px"; + label.style.color = "#6b5f55"; + toolbar.appendChild(label); } else { - toolbar.appendChild(button); + for (const preset of presets) { + const btn = document.createElement("button"); + btn.textContent = preset.name; + btn.style.cssText = ` + padding: 6px 12px; + background: #b14d2b; + color: white; + border: none; + border-radius: 8px; + cursor: pointer; + font-size: 12px; + `; + btn.addEventListener("click", () => { + chrome.runtime.sendMessage({ type: "RUN_PRESET", presetId: preset.id }); + }); + toolbar.appendChild(btn); + } + } + + document.body.appendChild(toolbar); +} + + + +function matchUrl(url, pattern) { + + if (!pattern) return false; + + const regex = new RegExp("^" + pattern.split("*").join(".*") + "$"); + + try { + + const urlObj = new URL(url); + + const target = urlObj.hostname + urlObj.pathname; + + return regex.test(target); + + } catch { + + return false; + + } + +} + + + +async function refreshToolbar() { + const { sites = [], presets = [], toolbarPosition = "bottom-right" } = await chrome.storage.local.get(["sites", "presets", "toolbarPosition"]); + const currentUrl = window.location.href; + const site = sites.find(s => matchUrl(currentUrl, s.urlPattern)); + + if (site) { + createToolbar(presets, toolbarPosition); } } -function extractPostingText() { - const contents = [...document.getElementsByClassName("modal__content")]; - if (!contents.length) { - return { ok: false, error: "No modal content found on this page." }; - } - // WaterlooWorks renders multiple modal containers; choose the longest visible text block. - const el = contents.reduce((best, cur) => - cur.innerText.length > best.innerText.length ? cur : best - ); - - const rawText = el.innerText; - const sanitized = sanitizePostingText(rawText); - - return { ok: true, rawText, sanitized }; -} - -chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { - if (message?.type !== "EXTRACT_POSTING") return; - - const result = extractPostingText(); - sendResponse(result); -}); - -chrome.runtime.onConnect.addListener((port) => { - if (port.name !== "wwcompanion-keepalive") return; - port.onDisconnect.addListener(() => {}); -}); const observer = new MutationObserver(() => { - ensureDefaultTaskButton(); + + // refreshToolbar(); // Debounce this? + }); -observer.observe(document.documentElement, { childList: true, subtree: true }); -ensureDefaultTaskButton(); + + +// observer.observe(document.documentElement, { childList: true, subtree: true }); + +refreshToolbar(); diff --git a/sitecompanion/manifest.json b/sitecompanion/manifest.json index e7d4804..59922fb 100644 --- a/sitecompanion/manifest.json +++ b/sitecompanion/manifest.json @@ -1,12 +1,12 @@ { "manifest_version": 3, - "name": "WWCompanion", - "version": "0.3.1", - "description": "AI companion for WaterlooWorks job postings.", + "name": "SiteCompanion", + "version": "0.4.0", + "description": "AI companion for site-bound text extraction and tasks.", "permissions": ["storage", "activeTab"], - "host_permissions": ["https://waterlooworks.uwaterloo.ca/*"], + "host_permissions": [""], "action": { - "default_title": "WWCompanion", + "default_title": "SiteCompanion", "default_popup": "popup.html" }, "background": { @@ -15,7 +15,7 @@ }, "content_scripts": [ { - "matches": ["https://waterlooworks.uwaterloo.ca/*"], + "matches": [""], "js": ["content.js"] } ], diff --git a/sitecompanion/popup.css b/sitecompanion/popup.css index b824e55..38016f4 100644 --- a/sitecompanion/popup.css +++ b/sitecompanion/popup.css @@ -158,7 +158,63 @@ select { } .hidden { - display: none; + display: none !important; +} + +.state-body { + display: grid; + gap: 10px; +} + +.state-body p { + margin: 0; + font-size: 12px; + line-height: 1.4; +} + +textarea#partialTextPaste { + width: 100%; + padding: 8px; + border-radius: 10px; + border: 1px solid var(--border); + background: var(--input-bg); + color: var(--input-fg); + font-size: 12px; + resize: vertical; +} + +.preview-box { + max-height: 120px; + overflow-y: auto; + padding: 8px; + border-radius: 10px; + background: var(--code-bg); + font-size: 11px; + white-space: pre-wrap; + border: 1px solid var(--border); +} + +.row { + display: flex; + gap: 8px; + justify-content: flex-end; +} + +.workspace-info { + font-size: 11px; + color: var(--muted); + margin-bottom: 8px; + font-style: italic; +} + +input[type="text"] { + width: 100%; + padding: 6px 8px; + border-radius: 10px; + border: 1px solid var(--border); + background: var(--input-bg); + color: var(--input-fg); + font-size: 12px; } button { diff --git a/sitecompanion/popup.html b/sitecompanion/popup.html index b91f578..c57e419 100644 --- a/sitecompanion/popup.html +++ b/sitecompanion/popup.html @@ -3,18 +3,46 @@ - WWCompanion + SiteCompanion
- WWCompanion - AI companion for WaterlooWorks. + SiteCompanion + AI companion for site-bound tasks.
-
+ + + + +
+
+ Workspace: Global +
@@ -38,8 +66,8 @@
- Posting: 0 chars - Prompt: 0 chars + Site Text: 0 chars + Task: 0 chars Idle
diff --git a/sitecompanion/popup.js b/sitecompanion/popup.js index a4ab4f1..7f4e607 100644 --- a/sitecompanion/popup.js +++ b/sitecompanion/popup.js @@ -18,11 +18,26 @@ const LAST_TASK_KEY = "lastSelectedTaskId"; const LAST_ENV_KEY = "lastSelectedEnvId"; const LAST_PROFILE_KEY = "lastSelectedProfileId"; +const unknownSiteState = document.getElementById("unknownSiteState"); +const extractionReviewState = document.getElementById("extractionReviewState"); +const normalExecutionState = document.getElementById("normalExecutionState"); +const partialTextPaste = document.getElementById("partialTextPaste"); +const extractFullBtn = document.getElementById("extractFullBtn"); +const extractedPreview = document.getElementById("extractedPreview"); +const urlPatternInput = document.getElementById("urlPatternInput"); +const retryExtractBtn = document.getElementById("retryExtractBtn"); +const confirmSiteBtn = document.getElementById("confirmSiteBtn"); +const currentWorkspaceName = document.getElementById("currentWorkspaceName"); + const state = { - postingText: "", + siteText: "", tasks: [], envs: [], profiles: [], + sites: [], + workspaces: [], + currentSite: null, + currentWorkspace: null, port: null, isAnalyzing: false, outputRaw: "", @@ -32,21 +47,65 @@ const state = { selectedProfileId: "" }; +async function switchState(stateName) { + unknownSiteState.classList.add("hidden"); + extractionReviewState.classList.add("hidden"); + normalExecutionState.classList.add("hidden"); + + if (stateName === "unknown") { + unknownSiteState.classList.remove("hidden"); + } else if (stateName === "review") { + extractionReviewState.classList.remove("hidden"); + } else if (stateName === "normal") { + normalExecutionState.classList.remove("hidden"); + } + await chrome.storage.local.set({ lastPopupState: stateName }); +} + +function matchUrl(url, pattern) { + if (!pattern) return false; + const regex = new RegExp("^" + pattern.split("*").join(".*") + "$"); + try { + const urlObj = new URL(url); + const target = urlObj.hostname + urlObj.pathname; + return regex.test(target); + } catch { + return false; + } +} + +async function detectSite(url) { + const { sites = [], workspaces = [] } = await getStorage(["sites", "workspaces"]); + state.sites = sites; + state.workspaces = workspaces; + + const site = sites.find(s => matchUrl(url, s.urlPattern)); + if (site) { + state.currentSite = site; + state.currentWorkspace = workspaces.find(w => w.id === site.workspaceId) || { name: "Global", id: "global" }; + currentWorkspaceName.textContent = state.currentWorkspace.name; + switchState("normal"); + return true; + } + + switchState("unknown"); + return false; +} + function getStorage(keys) { return new Promise((resolve) => chrome.storage.local.get(keys, resolve)); } -function buildUserMessage(resume, resumeType, task, posting) { - const header = resumeType === "Profile" ? "=== PROFILE ===" : "=== RESUME ==="; +function buildUserMessage(profileText, taskText, siteText) { return [ - header, - resume || "", + "=== Profile ===", + profileText || "", "", - "=== TASK ===", - task || "", + "=== Task ===", + taskText || "", "", - "=== JOB POSTING ===", - posting || "" + "=== Site Text ===", + siteText || "" ].join("\n"); } @@ -261,12 +320,12 @@ function setAnalyzing(isAnalyzing) { updateProfileSelectState(); } -function updatePostingCount() { - postingCountEl.textContent = `Posting: ${state.postingText.length} chars`; +function updateSiteTextCount() { + postingCountEl.textContent = `Site Text: ${state.siteText.length} chars`; } function updatePromptCount(count) { - promptCountEl.textContent = `Prompt: ${count} chars`; + promptCountEl.textContent = `Task: ${count} chars`; } function renderTasks(tasks) { @@ -399,14 +458,6 @@ async function persistSelections() { }); } -function isWaterlooWorksUrl(url) { - try { - return new URL(url).hostname === "waterlooworks.uwaterloo.ca"; - } catch { - return false; - } -} - function sendToActiveTab(message) { return new Promise((resolve, reject) => { chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { @@ -416,17 +467,12 @@ function sendToActiveTab(message) { return; } - if (!isWaterlooWorksUrl(tab.url || "")) { - reject(new Error("Open waterlooworks.uwaterloo.ca to use this.")); - return; - } - chrome.tabs.sendMessage(tab.id, message, (response) => { const error = chrome.runtime.lastError; if (error) { const msg = error.message && error.message.includes("Receiving end does not exist") - ? "Couldn't reach the page. Try refreshing WaterlooWorks and retry." + ? "Couldn't reach the page. Try refreshing and retry." : error.message; reject(new Error(msg)); return; @@ -486,6 +532,22 @@ function ensurePort() { } async function loadConfig() { + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + const currentUrl = tabs[0]?.url || ""; + + const { lastPopupState } = await getStorage(["lastPopupState"]); + await detectSite(currentUrl); + if (lastPopupState && lastPopupState !== "unknown") { + // If we had a state like 'review', we might want to stay there, + // but detectSite might have switched to 'normal' if it matched. + // AGENTS.md says popup state must be persisted. + if (state.currentSite && lastPopupState === "normal") { + await switchState("normal"); + } else if (!state.currentSite && (lastPopupState === "unknown" || lastPopupState === "review")) { + await switchState(lastPopupState); + } + } + const stored = await getStorage([ "tasks", "envConfigs", @@ -548,40 +610,40 @@ async function loadTheme() { async function handleExtract() { setStatus("Extracting..."); try { - const response = await sendToActiveTab({ type: "EXTRACT_POSTING" }); + const response = await sendToActiveTab({ type: "EXTRACT_FULL" }); if (!response?.ok) { - setStatus(response?.error || "No posting detected."); + setStatus(response?.error || "No text detected."); return false; } - state.postingText = response.sanitized || ""; - updatePostingCount(); + state.siteText = response.extracted || ""; + updateSiteTextCount(); updatePromptCount(0); - setStatus("Posting extracted."); + setStatus("Text extracted."); return true; } catch (error) { - setStatus(error.message || "Unable to extract posting."); + setStatus(error.message || "Unable to extract text."); return false; } } async function handleAnalyze() { - if (!state.postingText) { - setStatus("Extract a job posting first."); + if (!state.siteText) { + setStatus("Extract site text first."); return; } const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); const tab = tabs[0]; - if (!tab?.url || !isWaterlooWorksUrl(tab.url)) { - setStatus("Open waterlooworks.uwaterloo.ca to run tasks."); + if (!tab?.url) { + setStatus("Open a page to run tasks."); return; } const taskId = taskSelect.value; const task = state.tasks.find((item) => item.id === taskId); if (!task) { - setStatus("Select a task prompt."); + setStatus("Select a task."); return; } @@ -640,8 +702,7 @@ async function handleAnalyze() { const activeProfile = resolvedProfiles.find((entry) => entry.id === selectedProfileId) || resolvedProfiles[0]; - const resumeText = activeProfile?.text || resume || ""; - const resumeType = activeProfile?.type || "Resume"; + const profileText = activeProfile?.text || resume || ""; const isAdvanced = Boolean(activeConfig?.advanced); const resolvedApiUrl = activeConfig?.apiUrl || ""; const resolvedTemplate = activeConfig?.requestTemplate || ""; @@ -688,10 +749,9 @@ async function handleAnalyze() { } const promptText = buildUserMessage( - resumeText, - resumeType, + profileText, task.text || "", - state.postingText + state.siteText ); updatePromptCount(promptText.length); @@ -713,10 +773,9 @@ async function handleAnalyze() { apiKeyPrefix: resolvedApiKeyPrefix, model: resolvedModel, systemPrompt: resolvedSystemPrompt, - resume: resumeText, - resumeType, + profileText, taskText: task.text || "", - postingText: state.postingText, + siteText: state.siteText, tabId: tab.id } }); @@ -769,6 +828,81 @@ function handleCopyRaw() { void copyTextToClipboard(text, "Markdown"); } +partialTextPaste.addEventListener("input", async () => { + const text = partialTextPaste.value.trim(); + if (text.length < 5) return; + + setStatus("Finding scope..."); + try { + const response = await sendToActiveTab({ type: "FIND_SCOPE", text }); + if (response?.ok) { + state.siteText = response.extracted; + extractedPreview.textContent = state.siteText; + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + const url = new URL(tabs[0].url); + urlPatternInput.value = url.hostname + url.pathname + "*"; + switchState("review"); + setStatus("Review extraction."); + } + } catch (error) { + setStatus("Error finding scope."); + } +}); + +extractFullBtn.addEventListener("click", async () => { + setStatus("Extracting full text..."); + try { + const response = await sendToActiveTab({ type: "EXTRACT_FULL" }); + if (response?.ok) { + state.siteText = response.extracted; + extractedPreview.textContent = state.siteText; + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + const url = new URL(tabs[0].url); + urlPatternInput.value = url.hostname + url.pathname + "*"; + switchState("review"); + setStatus("Review extraction."); + } + } catch (error) { + setStatus("Error extracting text."); + } +}); + +retryExtractBtn.addEventListener("click", () => { + switchState("unknown"); + partialTextPaste.value = ""; + setStatus("Ready."); +}); + +confirmSiteBtn.addEventListener("click", async () => { + const pattern = urlPatternInput.value.trim(); + if (!pattern) { + setStatus("Enter a URL pattern."); + return; + } + + // AGENTS.md: No URL pattern may be a substring of another. + const conflict = state.sites.find(s => s.urlPattern.includes(pattern) || pattern.includes(s.urlPattern)); + if (conflict) { + setStatus("URL pattern conflict."); + return; + } + + const newSite = { + id: `site-${Date.now()}`, + urlPattern: pattern, + workspaceId: "global" // Default to global for now + }; + + state.sites.push(newSite); + await chrome.storage.local.set({ sites: state.sites }); + state.currentSite = newSite; + state.currentWorkspace = { name: "Global", id: "global" }; + currentWorkspaceName.textContent = "Global"; + switchState("normal"); + updateSiteTextCount(); + setStatus("Site saved."); +}); + runBtn.addEventListener("click", handleExtractAndAnalyze); abortBtn.addEventListener("click", handleAbort); settingsBtn.addEventListener("click", () => chrome.runtime.openOptionsPage()); @@ -788,7 +922,7 @@ profileSelect.addEventListener("change", () => { void persistSelections(); }); -updatePostingCount(); +updateSiteTextCount(); updatePromptCount(0); renderOutput(); setAnalyzing(false); diff --git a/sitecompanion/settings.css b/sitecompanion/settings.css index fc89b3f..a1969af 100644 --- a/sitecompanion/settings.css +++ b/sitecompanion/settings.css @@ -76,22 +76,77 @@ body { } .toc-links { - display: grid; - gap: 8px; + display: block; } -.toc a { +.toc ul { + list-style: none; + padding: 0; + margin: 0; + display: grid; + gap: 2px; +} + +.toc-sub { + padding-left: 12px !important; + margin-top: 2px !important; + display: none; +} + +.toc-sub.expanded { + display: grid !important; +} + +.toc-item { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 6px; + border-radius: 6px; + cursor: pointer; + font-size: 12px; + color: var(--ink); + text-decoration: none; +} + +.toc-item:hover { + background: var(--card-bg); +} + +.toc-item .toc-caret { + display: inline-block; + width: 10px; + text-align: center; + color: var(--muted); + font-size: 10px; + transition: transform 0.15s; +} + +.toc-item.expanded .toc-caret { + transform: rotate(90deg); +} + +.toc li a { + display: block; + padding: 4px 6px; + border-radius: 6px; color: var(--ink); text-decoration: none; font-size: 12px; - padding: 4px 6px; - border-radius: 8px; } -.toc a:hover { +.toc li a:hover { background: var(--card-bg); } +.toc-sub li a { + color: var(--muted); +} + +.toc-sub li a:hover { + color: var(--ink); +} + .sidebar-errors { margin-top: auto; border-radius: 10px; @@ -139,8 +194,9 @@ body { background: var(--panel); border: 1px solid var(--border); border-radius: 16px; - padding: 16px; + padding: 0; box-shadow: var(--panel-shadow); + overflow: hidden; } .panel-summary { @@ -150,40 +206,58 @@ body { align-items: baseline; justify-content: flex-start; gap: 12px; + padding: 12px 16px; + margin: 0; } -.panel-summary::-webkit-details-marker { - display: none; +/* Restore native marker but keep list-style: none from above? No, remove list-style: none to show marker. */ +/* Wait, display: flex might hide marker in some browsers. */ +/* Usually marker is ::marker pseudo-element on summary. */ +/* To show native marker, summary should be display: list-item or similar? */ +/* Actually, standard is display: block (or list-item). Flex might kill it. */ +/* If the user wants native glyphs, I should use list-item and maybe position the h2? */ + +/* Let's try reverting panel-summary to default display and styling h2 inline. */ + +.panel-summary { + cursor: pointer; + padding: 12px 16px; + margin: 0; + /* display: list-item; default */ } -.panel-caret { - display: inline-flex; - align-items: center; - width: 16px; - justify-content: center; - color: var(--muted); - font-weight: 700; - font-family: "Segoe UI Symbol", "Apple Symbols", system-ui, sans-serif; -} - -.panel-caret .caret-open { - display: none; -} - -.panel[open] .panel-caret .caret-open { +/* Need to align H2. */ +.panel-summary h2 { display: inline; } -.panel[open] .panel-caret .caret-closed { - display: none; +.sub-panel .panel-summary { + padding: 10px 12px; +} + +.sub-panel .panel-body { + padding: 0 12px 12px; } .panel-body { - margin-top: 12px; + margin-top: 0; + padding: 0 16px 16px; } .panel[open] .panel-summary { - margin-bottom: 6px; + margin-bottom: 0; + border-bottom: 1px solid var(--border); +} + +.sub-panel { + box-shadow: none; + background: var(--card-bg); + border: 1px solid var(--border); + margin-bottom: 12px; +} + +.sub-panel:last-child { + margin-bottom: 0; } .row { diff --git a/sitecompanion/settings.html b/sitecompanion/settings.html index 5c9188c..cc91433 100644 --- a/sitecompanion/settings.html +++ b/sitecompanion/settings.html @@ -3,13 +3,13 @@ - WWCompanion Settings + SiteCompanion Settings
-
WWCompanion Settings
-
Configure prompts, resume, and API access
+
SiteCompanion Settings
+
Configure workspaces, tasks, and API access
@@ -18,134 +18,204 @@
-
+
- -

Appearance

+

Global Configuration

-
- - -
-
-
- -
- - -

API KEYS

-
-
-
-
- -
-
-
-
- -
- - -

API

-
-
-
-
-
- + +
+ +

Appearance

+
+
+
+ + +
+
+ + +
-
-
+
+ + +
+ +

API KEYS

+
+
+
+
+ +
+
+
+
+ + +
+ +

API

+
+
+
+
+
+ +
+
+
+
+
+ + +
+ +
+

ENVIRONMENTS

+ Baseline environments +
+
+
+
+
+ +
+
+
+
+ + +
+ +
+

PROFILES

+ Baseline user contexts +
+
+
+
+
+ +
+
+
+
+ + +
+ +
+

TASKS

+ Baseline execution units +
+
+
+
+
+ +
+
+
+
+ + +
+ +
+

PRESETS

+ Toolbar shortcuts +
+
+
+
+
+ +
+
+
+
-
+
-
-

Environment

- API configuration and system prompt go here +

Workspaces

+ Namespace for sites and resources
- +
-
+
-
+
-
-

My Profiles

- Text to your resumes or generic profiles goes here +

Sites

+ Configure known sites
- +
-
-
-
- -
- - -
-

Task Presets

- Top task is the default -
-
-
-
-
- -
-
+
diff --git a/sitecompanion/settings.js b/sitecompanion/settings.js index f3f1d30..8ee4a76 100644 --- a/sitecompanion/settings.js +++ b/sitecompanion/settings.js @@ -10,10 +10,17 @@ const addTaskBtn = document.getElementById("addTaskBtn"); const tasksContainer = document.getElementById("tasks"); const addProfileBtn = document.getElementById("addProfileBtn"); const profilesContainer = document.getElementById("profiles"); +const addWorkspaceBtn = document.getElementById("addWorkspaceBtn"); +const workspacesContainer = document.getElementById("workspaces"); +const addSiteBtn = document.getElementById("addSiteBtn"); +const sitesContainer = document.getElementById("sites"); +const addPresetBtn = document.getElementById("addPresetBtn"); +const presetsContainer = document.getElementById("presets"); const statusEl = document.getElementById("status"); const statusSidebarEl = document.getElementById("statusSidebar"); const sidebarErrorsEl = document.getElementById("sidebarErrors"); const themeSelect = document.getElementById("themeSelect"); +const toolbarPositionSelect = document.getElementById("toolbarPositionSelect"); const OPENAI_DEFAULTS = { apiBaseUrl: "https://api.openai.com/v1", @@ -78,6 +85,21 @@ function newProfileId() { return `profile-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; } +function newWorkspaceId() { + if (crypto?.randomUUID) return crypto.randomUUID(); + return `ws-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; +} + +function newSiteId() { + if (crypto?.randomUUID) return crypto.randomUUID(); + return `site-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; +} + +function newPresetId() { + if (crypto?.randomUUID) return crypto.randomUUID(); + return `preset-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; +} + function buildChatUrlFromBase(baseUrl) { const trimmed = (baseUrl || "").trim().replace(/\/+$/, ""); if (!trimmed) return ""; @@ -833,22 +855,7 @@ function updateTaskEnvOptions() { scheduleSidebarErrors(); } -function collectEnvConfigs() { - const cards = [...envConfigsContainer.querySelectorAll(".env-config-card")]; - return cards.map((card) => { - const nameInput = card.querySelector(".env-config-name"); - const apiSelect = card.querySelector(".env-config-api-select"); - const promptInput = card.querySelector(".env-config-prompt"); - return { - id: card.dataset.id || newEnvConfigId(), - name: (nameInput?.value || "Default").trim(), - apiConfigId: apiSelect?.value || "", - systemPrompt: (promptInput?.value || "").trim() - }; - }); -} - -function buildProfileCard(profile) { +function buildProfileCard(profile, container = profilesContainer) { const card = document.createElement("div"); card.className = "profile-card"; card.dataset.id = profile.id || newProfileId(); @@ -864,24 +871,6 @@ function buildProfileCard(profile) { nameField.appendChild(nameLabel); nameField.appendChild(nameInput); - const typeField = document.createElement("div"); - typeField.className = "field"; - const typeLabel = document.createElement("label"); - typeLabel.textContent = "Type"; - const typeSelect = document.createElement("select"); - typeSelect.className = "profile-type"; - const resumeOption = document.createElement("option"); - resumeOption.value = "Resume"; - resumeOption.textContent = "Resume"; - const profileOption = document.createElement("option"); - profileOption.value = "Profile"; - profileOption.textContent = "Profile"; - typeSelect.appendChild(resumeOption); - typeSelect.appendChild(profileOption); - typeSelect.value = profile.type === "Profile" ? "Profile" : "Resume"; - typeField.appendChild(typeLabel); - typeField.appendChild(typeSelect); - const textField = document.createElement("div"); textField.className = "field"; const textLabel = document.createElement("label"); @@ -913,46 +902,40 @@ function buildProfileCard(profile) { addBelowBtn.textContent = "Add"; moveTopBtn.addEventListener("click", () => { - const first = profilesContainer.firstElementChild; + const first = container.firstElementChild; if (!first || first === card) return; - profilesContainer.insertBefore(card, first); - updateProfileControls(); + container.insertBefore(card, first); + updateProfileControls(container); updateTaskProfileOptions(); }); moveUpBtn.addEventListener("click", () => { const previous = card.previousElementSibling; if (!previous) return; - profilesContainer.insertBefore(card, previous); - updateProfileControls(); + container.insertBefore(card, previous); + updateProfileControls(container); updateTaskProfileOptions(); }); moveDownBtn.addEventListener("click", () => { const next = card.nextElementSibling; if (!next) return; - profilesContainer.insertBefore(card, next.nextElementSibling); - updateProfileControls(); + container.insertBefore(card, next.nextElementSibling); + updateProfileControls(container); updateTaskProfileOptions(); }); - actions.appendChild(moveTopBtn); - actions.appendChild(moveUpBtn); - actions.appendChild(moveDownBtn); - actions.appendChild(addBelowBtn); - addBelowBtn.addEventListener("click", () => { const name = buildUniqueDefaultName( - collectNames(profilesContainer, ".profile-name") + collectNames(container, ".profile-name") ); const newCard = buildProfileCard({ id: newProfileId(), name, - text: "", - type: "Resume" - }); + text: "" + }, container); card.insertAdjacentElement("afterend", newCard); - updateProfileControls(); + updateProfileControls(container); updateTaskProfileOptions(); }); @@ -961,21 +944,19 @@ function buildProfileCard(profile) { duplicateBtn.className = "ghost duplicate"; duplicateBtn.textContent = "Duplicate"; duplicateBtn.addEventListener("click", () => { - const names = collectNames(profilesContainer, ".profile-name"); - const copy = collectProfiles().find((entry) => entry.id === card.dataset.id) || { + const names = collectNames(container, ".profile-name"); + const copy = collectProfiles(container).find((entry) => entry.id === card.dataset.id) || { id: card.dataset.id, name: nameInput.value || "Default", - text: textArea.value || "", - type: typeSelect.value || "Resume" + text: textArea.value || "" }; const newCard = buildProfileCard({ id: newProfileId(), name: ensureUniqueName(`${copy.name || "Default"} Copy`, names), - text: copy.text, - type: copy.type || "Resume" - }); + text: copy.text + }, container); card.insertAdjacentElement("afterend", newCard); - updateProfileControls(); + updateProfileControls(container); updateTaskProfileOptions(); }); @@ -985,40 +966,41 @@ function buildProfileCard(profile) { deleteBtn.textContent = "Delete"; deleteBtn.addEventListener("click", () => { card.remove(); - updateProfileControls(); + updateProfileControls(container); updateTaskProfileOptions(); }); + actions.appendChild(moveTopBtn); + actions.appendChild(moveUpBtn); + actions.appendChild(moveDownBtn); + actions.appendChild(addBelowBtn); actions.appendChild(duplicateBtn); actions.appendChild(deleteBtn); nameInput.addEventListener("input", () => updateTaskProfileOptions()); card.appendChild(nameField); - card.appendChild(typeField); card.appendChild(textField); card.appendChild(actions); return card; } -function collectProfiles() { - const cards = [...profilesContainer.querySelectorAll(".profile-card")]; +function collectProfiles(container = profilesContainer) { + const cards = [...container.querySelectorAll(".profile-card")]; return cards.map((card) => { const nameInput = card.querySelector(".profile-name"); const textArea = card.querySelector(".profile-text"); - const typeSelect = card.querySelector(".profile-type"); return { id: card.dataset.id || newProfileId(), name: (nameInput?.value || "Default").trim(), - text: (textArea?.value || "").trim(), - type: typeSelect?.value || "Resume" + text: (textArea?.value || "").trim() }; }); } -function updateProfileControls() { - const cards = [...profilesContainer.querySelectorAll(".profile-card")]; +function updateProfileControls(container = profilesContainer) { + const cards = [...container.querySelectorAll(".profile-card")]; cards.forEach((card, index) => { const moveTopBtn = card.querySelector(".move-top"); const moveUpBtn = card.querySelector(".move-up"); @@ -1098,7 +1080,298 @@ function updateEnvApiOptions() { updateTaskEnvOptions(); } -function buildTaskCard(task) { +function collectWorkspaces() { + const cards = [...workspacesContainer.querySelectorAll(".workspace-card")]; + return cards.map((card) => { + const nameInput = card.querySelector(".workspace-name"); + const themeSelect = card.querySelector(".workspace-theme"); + + // Collect nested resources + const envsContainer = card.querySelector(".workspace-envs"); + const profilesContainer = card.querySelector(".workspace-profiles"); + const tasksContainer = card.querySelector(".workspace-tasks"); + const presetsContainer = card.querySelector(".workspace-presets"); + + // We can reuse collect functions if they accept a container! + // But collectEnvConfigs currently returns objects with flat IDs. + // We'll need to ensure we don't lose the nested nature or we handle it during save. + + // Actually, saveSettings stores workspaces array. If we put the resources inside, it works. + + return { + id: card.dataset.id || newWorkspaceId(), + name: (nameInput?.value || "Untitled Workspace").trim(), + theme: themeSelect?.value || "inherit", + envConfigs: envsContainer ? collectEnvConfigs(envsContainer) : [], + profiles: profilesContainer ? collectProfiles(profilesContainer) : [], + tasks: tasksContainer ? collectTasks(tasksContainer) : [], + presets: presetsContainer ? collectPresets(presetsContainer) : [] + }; + }); +} + +function collectPresets(container = presetsContainer) { + const cards = [...container.querySelectorAll(".preset-card")]; + return cards.map((card) => { + const nameInput = card.querySelector(".preset-name"); + const envSelect = card.querySelector(".preset-env"); + const profileSelect = card.querySelector(".preset-profile"); + const taskSelect = card.querySelector(".preset-task"); + return { + id: card.dataset.id || newPresetId(), + name: (nameInput?.value || "Untitled Preset").trim(), + envId: envSelect?.value || "", + profileId: profileSelect?.value || "", + taskId: taskSelect?.value || "" + }; + }); +} + +function collectEnvConfigs(container = envConfigsContainer) { + const cards = [...container.querySelectorAll(".env-config-card")]; + return cards.map((card) => { + const nameInput = card.querySelector(".env-config-name"); + const apiSelect = card.querySelector(".env-config-api-select"); + const promptInput = card.querySelector(".env-config-prompt"); + return { + id: card.dataset.id || newEnvConfigId(), + name: (nameInput?.value || "Default").trim(), + apiConfigId: apiSelect?.value || "", + systemPrompt: (promptInput?.value || "").trim() + }; + }); +} + +function renderWorkspaceSection(title, containerClass, items, builder, newItemFactory) { + const details = document.createElement("details"); + details.className = "panel sub-panel"; + details.style.marginTop = "10px"; + details.style.border = "1px solid var(--border)"; + details.style.borderRadius = "8px"; + details.style.padding = "8px"; + + const summary = document.createElement("summary"); + summary.className = "panel-summary"; + summary.style.cursor = "pointer"; + summary.innerHTML = `

${title}

`; + details.appendChild(summary); + + const body = document.createElement("div"); + body.className = "panel-body"; + body.style.paddingTop = "10px"; + + const listContainer = document.createElement("div"); + listContainer.className = containerClass; + + if (items && Array.isArray(items)) { + for (const item of items) { + listContainer.appendChild(builder(item, listContainer)); + } + } + + const row = document.createElement("div"); + row.className = "row"; + row.style.marginTop = "8px"; + + const addBtn = document.createElement("button"); + addBtn.className = "ghost"; + addBtn.type = "button"; + addBtn.textContent = "Add"; + addBtn.addEventListener("click", () => { + const newItem = newItemFactory(listContainer); + const newCard = builder(newItem, listContainer); + listContainer.appendChild(newCard); + scheduleSidebarErrors(); + }); + + row.appendChild(addBtn); + body.appendChild(row); + body.appendChild(listContainer); + details.appendChild(body); + + return details; +} + +function buildWorkspaceCard(ws) { + const card = document.createElement("div"); + card.className = "workspace-card panel"; + card.dataset.id = ws.id || newWorkspaceId(); + + const header = document.createElement("div"); + header.className = "workspace-header"; + + const nameInput = document.createElement("input"); + nameInput.type = "text"; + nameInput.value = ws.name || ""; + nameInput.className = "workspace-name"; + nameInput.placeholder = "Workspace Name"; + + const themeSelect = document.createElement("select"); + themeSelect.className = "workspace-theme"; + const themes = ["inherit", "light", "dark", "system"]; + for (const t of themes) { + const opt = document.createElement("option"); + opt.value = t; + opt.textContent = t.charAt(0).toUpperCase() + t.slice(1); + themeSelect.appendChild(opt); + } + themeSelect.value = ws.theme || "inherit"; + + const deleteBtn = document.createElement("button"); + deleteBtn.type = "button"; + deleteBtn.className = "ghost delete"; + deleteBtn.textContent = "Delete"; + deleteBtn.addEventListener("click", () => { + if (confirm(`Delete workspace "${ws.name}"? All items will move to global.`)) { + card.remove(); + scheduleSidebarErrors(); + } + }); + + header.appendChild(nameInput); + header.appendChild(themeSelect); + header.appendChild(deleteBtn); + card.appendChild(header); + + // Subsections + const envSection = renderWorkspaceSection( + "Environments", + "workspace-envs", + ws.envConfigs, + buildEnvConfigCard, + (container) => ({ + id: newEnvConfigId(), + name: buildUniqueDefaultName(collectNames(container, ".env-config-name")), + apiConfigId: collectApiConfigs()[0]?.id || "", + systemPrompt: DEFAULT_SYSTEM_PROMPT + }) + ); + card.appendChild(envSection); + + const profileSection = renderWorkspaceSection( + "Profiles", + "workspace-profiles", + ws.profiles, + buildProfileCard, + (container) => ({ + id: newProfileId(), + name: buildUniqueDefaultName(collectNames(container, ".profile-name")), + text: "" + }) + ); + card.appendChild(profileSection); + + const taskSection = renderWorkspaceSection( + "Tasks", + "workspace-tasks", + ws.tasks, + buildTaskCard, + (container) => ({ + id: newTaskId(), + name: buildUniqueDefaultName(collectNames(container, ".task-name")), + text: "", + defaultEnvId: "", + defaultProfileId: "" + }) + ); + card.appendChild(taskSection); + + const presetSection = renderWorkspaceSection( + "Presets", + "workspace-presets", + ws.presets, + buildPresetCard, + (container) => ({ + id: newPresetId(), + name: "New Preset", + envId: "", + profileId: "", + taskId: "" + }) + ); + card.appendChild(presetSection); + + return card; +} + +function collectSites() { + const cards = [...sitesContainer.querySelectorAll(".site-card")]; + return cards.map((card) => { + const patternInput = card.querySelector(".site-pattern"); + const workspaceSelect = card.querySelector(".site-workspace"); + return { + id: card.dataset.id || newSiteId(), + urlPattern: (patternInput?.value || "").trim(), + workspaceId: workspaceSelect?.value || "global" + }; + }); +} + +function buildSiteCard(site) { + const card = document.createElement("div"); + card.className = "site-card panel"; + card.dataset.id = site.id || newSiteId(); + + const row = document.createElement("div"); + row.className = "row"; + row.style.alignItems = "flex-end"; + + const patternField = document.createElement("div"); + patternField.className = "field"; + patternField.style.flex = "1"; + const patternLabel = document.createElement("label"); + patternLabel.textContent = "URL Pattern"; + const patternInput = document.createElement("input"); + patternInput.type = "text"; + patternInput.value = site.urlPattern || ""; + patternInput.className = "site-pattern"; + patternInput.placeholder = "example.com/*"; + patternField.appendChild(patternLabel); + patternField.appendChild(patternInput); + + const wsField = document.createElement("div"); + wsField.className = "field"; + const wsLabel = document.createElement("label"); + wsLabel.textContent = "Workspace"; + const wsSelect = document.createElement("select"); + wsSelect.className = "site-workspace"; + + // Populate workspaces + const workspaces = collectWorkspaces(); + const globalOpt = document.createElement("option"); + globalOpt.value = "global"; + globalOpt.textContent = "Global"; + wsSelect.appendChild(globalOpt); + + for (const ws of workspaces) { + const opt = document.createElement("option"); + opt.value = ws.id; + opt.textContent = ws.name; + wsSelect.appendChild(opt); + } + wsSelect.value = site.workspaceId || "global"; + + wsField.appendChild(wsLabel); + wsField.appendChild(wsSelect); + + const deleteBtn = document.createElement("button"); + deleteBtn.type = "button"; + deleteBtn.className = "ghost delete"; + deleteBtn.textContent = "Delete"; + deleteBtn.addEventListener("click", () => { + card.remove(); + scheduleSidebarErrors(); + }); + + row.appendChild(patternField); + row.appendChild(wsField); + row.appendChild(deleteBtn); + card.appendChild(row); + + return card; +} + +function buildTaskCard(task, container = tasksContainer) { const card = document.createElement("div"); card.className = "task-card"; card.dataset.id = task.id || newTaskId(); @@ -1137,7 +1410,7 @@ function buildTaskCard(task) { const textField = document.createElement("div"); textField.className = "field"; const textLabel = document.createElement("label"); - textLabel.textContent = "Task prompt"; + textLabel.textContent = "Task template"; const textArea = document.createElement("textarea"); textArea.rows = 6; textArea.value = task.text || ""; @@ -1151,63 +1424,51 @@ function buildTaskCard(task) { moveTopBtn.type = "button"; moveTopBtn.className = "ghost move-top"; moveTopBtn.textContent = "Top"; - moveTopBtn.setAttribute("aria-label", "Move task to top"); - moveTopBtn.setAttribute("title", "Move to top"); const moveUpBtn = document.createElement("button"); moveUpBtn.type = "button"; moveUpBtn.className = "ghost move-up"; moveUpBtn.textContent = "Up"; - moveUpBtn.setAttribute("aria-label", "Move task up"); - moveUpBtn.setAttribute("title", "Move up"); const moveDownBtn = document.createElement("button"); moveDownBtn.type = "button"; moveDownBtn.className = "ghost move-down"; moveDownBtn.textContent = "Down"; - moveDownBtn.setAttribute("aria-label", "Move task down"); - moveDownBtn.setAttribute("title", "Move down"); const addBelowBtn = document.createElement("button"); addBelowBtn.type = "button"; addBelowBtn.className = "ghost add-below"; addBelowBtn.textContent = "Add"; - addBelowBtn.setAttribute("aria-label", "Add task below"); - addBelowBtn.setAttribute("title", "Add below"); const duplicateBtn = document.createElement("button"); duplicateBtn.type = "button"; duplicateBtn.className = "ghost duplicate"; duplicateBtn.textContent = "Duplicate"; - duplicateBtn.setAttribute("aria-label", "Duplicate task"); - duplicateBtn.setAttribute("title", "Duplicate"); const deleteBtn = document.createElement("button"); deleteBtn.type = "button"; deleteBtn.className = "ghost delete"; deleteBtn.textContent = "Delete"; - deleteBtn.setAttribute("aria-label", "Delete task"); - deleteBtn.setAttribute("title", "Delete"); moveTopBtn.addEventListener("click", () => { - const first = tasksContainer.firstElementChild; + const first = container.firstElementChild; if (!first || first === card) return; - tasksContainer.insertBefore(card, first); - updateTaskControls(); + container.insertBefore(card, first); + updateTaskControls(container); }); moveUpBtn.addEventListener("click", () => { const previous = card.previousElementSibling; if (!previous) return; - tasksContainer.insertBefore(card, previous); - updateTaskControls(); + container.insertBefore(card, previous); + updateTaskControls(container); }); moveDownBtn.addEventListener("click", () => { const next = card.nextElementSibling; if (!next) return; - tasksContainer.insertBefore(card, next.nextElementSibling); - updateTaskControls(); + container.insertBefore(card, next.nextElementSibling); + updateTaskControls(container); }); addBelowBtn.addEventListener("click", () => { const name = buildUniqueDefaultName( - collectNames(tasksContainer, ".task-name") + collectNames(container, ".task-name") ); const newCard = buildTaskCard({ id: newTaskId(), @@ -1215,9 +1476,9 @@ function buildTaskCard(task) { text: "", defaultEnvId: getTopEnvId(), defaultProfileId: getTopProfileId() - }); + }, container); card.insertAdjacentElement("afterend", newCard); - updateTaskControls(); + updateTaskControls(container); updateTaskEnvOptions(); updateTaskProfileOptions(); }); @@ -1227,22 +1488,22 @@ function buildTaskCard(task) { id: newTaskId(), name: ensureUniqueName( `${nameInput.value || "Untitled"} Copy`, - collectNames(tasksContainer, ".task-name") + collectNames(container, ".task-name") ), text: textArea.value, defaultEnvId: envSelect.value || "", defaultProfileId: profileSelect.value || "" }; - const newCard = buildTaskCard(copy); + const newCard = buildTaskCard(copy, container); card.insertAdjacentElement("afterend", newCard); - updateTaskControls(); + updateTaskControls(container); updateTaskEnvOptions(); updateTaskProfileOptions(); }); deleteBtn.addEventListener("click", () => { card.remove(); - updateTaskControls(); + updateTaskControls(container); }); actions.appendChild(moveTopBtn); @@ -1261,8 +1522,111 @@ function buildTaskCard(task) { return card; } -function updateTaskControls() { - const cards = [...tasksContainer.querySelectorAll(".task-card")]; +function collectPresets() { + const cards = [...presetsContainer.querySelectorAll(".preset-card")]; + return cards.map((card) => { + const nameInput = card.querySelector(".preset-name"); + const envSelect = card.querySelector(".preset-env"); + const profileSelect = card.querySelector(".preset-profile"); + const taskSelect = card.querySelector(".preset-task"); + return { + id: card.dataset.id || newPresetId(), + name: (nameInput?.value || "Untitled Preset").trim(), + envId: envSelect?.value || "", + profileId: profileSelect?.value || "", + taskId: taskSelect?.value || "" + }; + }); +} + +function buildPresetCard(preset) { + const card = document.createElement("div"); + card.className = "preset-card"; + card.dataset.id = preset.id || newPresetId(); + + const nameField = document.createElement("div"); + nameField.className = "field"; + const nameLabel = document.createElement("label"); + nameLabel.textContent = "Name"; + const nameInput = document.createElement("input"); + nameInput.type = "text"; + nameInput.value = preset.name || ""; + nameInput.className = "preset-name"; + nameField.appendChild(nameLabel); + nameField.appendChild(nameInput); + + const envField = document.createElement("div"); + envField.className = "field"; + const envLabel = document.createElement("label"); + envLabel.textContent = "Environment"; + const envSelect = document.createElement("select"); + envSelect.className = "preset-env"; + const envs = collectEnvConfigs(); // Global only for now + for (const env of envs) { + const opt = document.createElement("option"); + opt.value = env.id; + opt.textContent = env.name; + envSelect.appendChild(opt); + } + envSelect.value = preset.envId || (envs[0]?.id || ""); + envField.appendChild(envLabel); + envField.appendChild(envSelect); + + const profileField = document.createElement("div"); + profileField.className = "field"; + const profileLabel = document.createElement("label"); + profileLabel.textContent = "Profile"; + const profileSelect = document.createElement("select"); + profileSelect.className = "preset-profile"; + const profiles = collectProfiles(); // Global only + for (const p of profiles) { + const opt = document.createElement("option"); + opt.value = p.id; + opt.textContent = p.name; + profileSelect.appendChild(opt); + } + profileSelect.value = preset.profileId || (profiles[0]?.id || ""); + profileField.appendChild(profileLabel); + profileField.appendChild(profileSelect); + + const taskField = document.createElement("div"); + taskField.className = "field"; + const taskLabel = document.createElement("label"); + taskLabel.textContent = "Task"; + const taskSelect = document.createElement("select"); + taskSelect.className = "preset-task"; + const tasks = collectTasks(); // Global only + for (const t of tasks) { + const opt = document.createElement("option"); + opt.value = t.id; + opt.textContent = t.name; + taskSelect.appendChild(opt); + } + taskSelect.value = preset.taskId || (tasks[0]?.id || ""); + taskField.appendChild(taskLabel); + taskField.appendChild(taskSelect); + + const deleteBtn = document.createElement("button"); + deleteBtn.type = "button"; + deleteBtn.className = "ghost delete"; + deleteBtn.textContent = "Delete"; + deleteBtn.style.marginTop = "8px"; + deleteBtn.addEventListener("click", () => { + card.remove(); + scheduleSidebarErrors(); + }); + + card.appendChild(nameField); + card.appendChild(envField); + card.appendChild(profileField); + card.appendChild(taskField); + card.appendChild(deleteBtn); + + return card; +} + +function updateTaskControls(container = tasksContainer) { + const cards = [...container.querySelectorAll(".task-card")]; cards.forEach((card, index) => { const moveTopBtn = card.querySelector(".move-top"); const moveUpBtn = card.querySelector(".move-up"); @@ -1274,8 +1638,8 @@ function updateTaskControls() { scheduleSidebarErrors(); } -function collectTasks() { - const cards = [...tasksContainer.querySelectorAll(".task-card")]; +function collectTasks(container = tasksContainer) { + const cards = [...container.querySelectorAll(".task-card")]; return cards.map((card) => { const nameInput = card.querySelector(".task-name"); const textArea = card.querySelector(".task-text"); @@ -1418,7 +1782,11 @@ async function loadSettings() { systemPrompt = "", resume = "", tasks = [], - theme = "system" + presets = [], + theme = "system", + workspaces = [], + sites = [], + toolbarPosition = "bottom-right" } = await getStorage([ "apiKey", "apiKeys", @@ -1435,12 +1803,42 @@ async function loadSettings() { "systemPrompt", "resume", "tasks", - "theme" + "presets", + "theme", + "workspaces", + "sites", + "toolbarPosition" ]); themeSelect.value = theme; applyTheme(theme); + + if (toolbarPositionSelect) { + toolbarPositionSelect.value = toolbarPosition; + } + // Load basic resources first so they are available for presets/workspaces + envConfigsContainer.innerHTML = ""; + // ... (existing logic handles this later) + + // Wait, I need to make sure collectEnvConfigs etc work. + // loadSettings currently renders cards later in the function. + // I need to ensure render order. + + // Actually, loadSettings renders cards in order. I should just add presets rendering at the end. + + workspacesContainer.innerHTML = ""; + for (const ws of workspaces) { + workspacesContainer.appendChild(buildWorkspaceCard(ws)); + } + + sitesContainer.innerHTML = ""; + for (const site of sites) { + sitesContainer.appendChild(buildSiteCard(site)); + } + + // I'll render presets after tasks are rendered. + let resolvedKeys = Array.isArray(apiKeys) ? apiKeys : []; let resolvedActiveId = activeApiKeyId; @@ -1649,15 +2047,25 @@ async function loadSettings() { updateTaskControls(); updateTaskEnvOptions(); updateTaskProfileOptions(); + + presetsContainer.innerHTML = ""; + for (const preset of presets) { + presetsContainer.appendChild(buildPresetCard(preset)); + } + updateSidebarErrors(); + updateToc(workspaces, sites); } async function saveSettings() { const tasks = collectTasks(); + const presets = collectPresets(); const apiKeys = collectApiKeys(); const apiConfigs = collectApiConfigs(); const envConfigs = collectEnvConfigs(); const profiles = collectProfiles(); + const workspaces = collectWorkspaces(); + const sites = collectSites(); const activeEnvConfigId = envConfigs[0]?.id || ""; const activeEnv = envConfigs[0]; const activeApiConfigId = @@ -1676,9 +2084,12 @@ async function saveSettings() { activeEnvConfigId, systemPrompt: activeEnv?.systemPrompt || "", profiles, - resume: profiles[0]?.text || "", tasks, - theme: themeSelect.value + presets, + theme: themeSelect.value, + toolbarPosition: toolbarPositionSelect ? toolbarPositionSelect.value : "bottom-right", + workspaces, + sites }); setStatus("Saved."); } @@ -1697,14 +2108,14 @@ addTaskBtn.addEventListener("click", () => { text: "", defaultEnvId: getTopEnvId(), defaultProfileId: getTopProfileId() - }); + }, tasksContainer); const first = tasksContainer.firstElementChild; if (first) { tasksContainer.insertBefore(newCard, first); } else { tasksContainer.appendChild(newCard); } - updateTaskControls(); + updateTaskControls(tasksContainer); updateTaskEnvOptions(); updateTaskProfileOptions(); }); @@ -1779,34 +2190,202 @@ addProfileBtn.addEventListener("click", () => { const newCard = buildProfileCard({ id: newProfileId(), name, - text: "", - type: "Resume" - }); + text: "" + }, profilesContainer); const first = profilesContainer.firstElementChild; if (first) { profilesContainer.insertBefore(newCard, first); } else { profilesContainer.appendChild(newCard); } - updateProfileControls(); + updateProfileControls(profilesContainer); updateTaskProfileOptions(); }); +addWorkspaceBtn.addEventListener("click", () => { + const newCard = buildWorkspaceCard({ + id: newWorkspaceId(), + name: "New Workspace", + theme: "inherit" + }); + workspacesContainer.appendChild(newCard); + scheduleSidebarErrors(); + updateToc(collectWorkspaces(), collectSites()); +}); + +addSiteBtn.addEventListener("click", () => { + const newCard = buildSiteCard({ + id: newSiteId(), + urlPattern: "", + workspaceId: "global" + }); + sitesContainer.appendChild(newCard); + scheduleSidebarErrors(); + updateToc(collectWorkspaces(), collectSites()); +}); + +addPresetBtn.addEventListener("click", () => { + const newCard = buildPresetCard({ + id: newPresetId(), + name: "New Preset", + envId: "", + profileId: "", + taskId: "" + }); + presetsContainer.appendChild(newCard); + scheduleSidebarErrors(); +}); + themeSelect.addEventListener("change", () => applyTheme(themeSelect.value)); themeSelect.addEventListener("change", () => applyTheme(themeSelect.value)); loadSettings(); -document.querySelectorAll(".toc a").forEach((link) => { - link.addEventListener("click", (event) => { - const href = link.getAttribute("href"); - if (!href || !href.startsWith("#")) return; - const target = document.querySelector(href); - if (target && target.tagName === "DETAILS") { - target.open = true; +function updateToc(workspaces, sites) { + const wsList = document.getElementById("toc-workspaces-list"); + if (!wsList) return; + + wsList.innerHTML = ""; + for (const ws of workspaces) { + const li = document.createElement("li"); + + const itemDiv = document.createElement("div"); + itemDiv.className = "toc-item"; + + const caret = document.createElement("span"); + caret.className = "toc-caret"; + caret.textContent = "▸"; + + const a = document.createElement("a"); + a.href = "#"; + a.textContent = ws.name || "Untitled"; + + itemDiv.appendChild(caret); + itemDiv.appendChild(a); + + const subUl = document.createElement("ul"); + subUl.className = "toc-sub hidden"; + + const sections = ["Environments", "Profiles", "Tasks", "Presets"]; + for (const section of sections) { + const subLi = document.createElement("li"); + const subA = document.createElement("a"); + subA.textContent = section; + subA.href = "#"; + subA.addEventListener("click", (e) => { + e.preventDefault(); + e.stopPropagation(); + const card = document.querySelector(`.workspace-card[data-id="${ws.id}"]`); + if (card) { + // Find details with summary text containing section name + const details = [...card.querySelectorAll("details")].find(d => + d.querySelector(".panel-summary").textContent.includes(section) + ); + if (details) { + details.open = true; + details.scrollIntoView({ behavior: "smooth", block: "start" }); + document.getElementById("workspaces-panel").open = true; + } else { + card.scrollIntoView({ behavior: "smooth", block: "start" }); + document.getElementById("workspaces-panel").open = true; + } + } + }); + subLi.appendChild(subA); + subUl.appendChild(subLi); } - }); -}); + + itemDiv.addEventListener("click", (e) => { + // Toggle if not clicking the link directly + if (!e.target.closest("a")) { + e.preventDefault(); + e.stopPropagation(); + subUl.classList.toggle("expanded"); + itemDiv.classList.toggle("expanded"); + } + }); + + a.addEventListener("click", (e) => { + e.preventDefault(); + e.stopPropagation(); + const card = document.querySelector(`.workspace-card[data-id="${ws.id}"]`); + if (card) { + card.scrollIntoView({ behavior: "smooth", block: "start" }); + document.getElementById("workspaces-panel").open = true; + + // Also expand sub-list + subUl.classList.add("expanded"); + itemDiv.classList.add("expanded"); + } + }); + + li.appendChild(itemDiv); + li.appendChild(subUl); + wsList.appendChild(li); + } + + const sitesList = document.getElementById("toc-sites-list"); + if (sitesList) { + sitesList.innerHTML = ""; + for (const site of sites) { + const li = document.createElement("li"); + const a = document.createElement("a"); + a.textContent = site.urlPattern || "Untitled Site"; + a.href = "#"; + a.addEventListener("click", (e) => { + e.preventDefault(); + e.stopPropagation(); + const card = document.querySelector(`.site-card[data-id="${site.id}"]`); + if (card) { + card.scrollIntoView({ behavior: "smooth", block: "center" }); + document.getElementById("sites-panel").open = true; + } + }); + li.appendChild(a); + sitesList.appendChild(li); + } + } +} + +function initToc() { + const items = document.querySelectorAll(".toc-item"); + items.forEach(item => { + item.addEventListener("click", (e) => { + const sub = item.nextElementSibling; + + // Handle link click + if (e.target.closest("a")) { + const link = e.target.closest("a"); + const href = link.getAttribute("href"); + if (href && href.startsWith("#")) { + // Let default behavior happen? No, prevent default if we want smooth scroll/open + // But here we rely on anchor. + // Just expand TOC. + if (sub && sub.classList.contains("toc-sub")) { + sub.classList.add("expanded"); + item.classList.add("expanded"); + } + // Open details + const target = document.querySelector(href); + if (target && target.tagName === "DETAILS") { + target.open = true; + } + } + return; + } + + // Toggle sub-list on row click (excluding link) + if (sub && sub.classList.contains("toc-sub")) { + e.preventDefault(); + e.stopPropagation(); + sub.classList.toggle("expanded"); + item.classList.toggle("expanded"); + } + }); + }); +} + +document.addEventListener("DOMContentLoaded", initToc); document.addEventListener("input", scheduleSidebarErrors); document.addEventListener("change", scheduleSidebarErrors);