diff --git a/README.md b/README.md new file mode 100644 index 0000000..f6e7a29 --- /dev/null +++ b/README.md @@ -0,0 +1,239 @@ +# SiteCompanion +SiteCompanion is a browser extension that allows you to run AI +pipelines on the visible text of any site. + +> SiteCompanion saves you from copying and pasting to run AI on sites. + +## Setup +1. Download the code (will be published to the Chrome Store soon) and + load the extension code (`sitecompanion/`) as an unpacked + extension. +2. Configure your API keys, API endpoints, environments, profiles, and + tasks. +3. Go to a site. +4. Choose to either paste in part of the site for structured matching + or use the full text of the site. +5. Start running your tasks! + +## Execution +How `SiteCompanion` works. + +See [Configurations and Terms](#configurations-and-terms) for details +on specific terms. + +### Model +The execution of `SiteCompanion` strictly follows this model: + +``` +Extract Site Text -> Build Prompt -> Run (Send Request) -> Receive Output -> Stop +``` + +### Prompt Building +The prompt will always be built in the following order: + +``` +System prompt -> Profile -> Task prompt -> Extracted Site Text +``` + +### Extension Popup +This is the control panel for `SiteCompanion` on sites. + +The following sections describe the modes of the extension popup. + +#### Configuration - Pasting Mode +This is the mode the popup UI is in when on an unknown site. + +You can choose to try extracting the minimal enclosing class that +contains the pasted text or the full text of the body of the current +site. Choosing either will proceed to [Confirmation +Mode](#configuration-confirmation-mode). + +#### Configuration - Confirmation Mode +This is the mode to review the extracted text based on your current +site's contents and confirm the configuration of the current site +including the name, URL match pattern and the workspace it belongs to. + +Confirming will add the current site to the list of known sites and +enter [Normal Mode](#normal-mode). + +See [Site-Specific Configurations](#site-specific-configurations) for +configuration details. + +#### Normal Mode +This is the mode where most of the actions happen. + +The knobs for running a task: +- Environment: the environment in which the task is running. +- Profile: the user profile used by the task. +- Task: the task to run. +- "Custom" button: enters [Custom Mode](#custom-mode). +- "Run" button: run the task with the current environment and profile. + +And a few buttons for the output buffer: +- "Copy" button: copies the contents of the output buffer in text. +- "Copy Markdown" button: copies the contents of the output buffer + with Markdown formatting. +- "Clear" button: clears the contents of the output buffer. + +#### Custom Mode +This is a variant of [Normal Mode](#normal-mode) to allow editing of a +custom, task prompt that's not saved to the configuration of the +extension. + +The "Normal" button will go back to [Normal Mode](#normal-mode). + +*The environment and profile selectors will always be active when in +this mode.* + +### Toolbar +Place predefined shortcuts for environment + profile + task +combinations in a floating toolbar. + +*Note that clicking on the toolbar will open the popup and run the +shortcut.* + +## Configurations and Terms +The full list of configuration options and details about the terms we +use. + +### Scope and Workspaces +Every operable site for `SiteCompanion` is in a workspace. + +There are three scopes: +1. Global workspace ("global" for short): defines the baseline + configurations. +2. User-defined workspace ("workspace" for short): defines the + configurations for a class of workflows. Workspaces always inherit + the global configurations. +3. Site-specific ("site" for short): defines the configuration for each + site. May inherit the global workspace or a user-defined + workspace. Must always inherit a workspace. + +`SiteCompanion` operates on a specific site at a time. + +### Configuration Inheritance +Each scope may inherit some configurations from its parent, and can +**always** override them by shadowing. + +*Any configuration that can be inherited by a workspace must be +inheritable by a site.* + +If not explicitly mentioned, a configuration is **always** +inheritable. + +#### Disabling Inherited Configurations +Any inherited configuration can be disabled in the current scope, and +a child may only inherit the enabled configurations of its parent. + +The state of an inherited configuration is configurable by clicking +the toggling buttons in the "Inherited" sections of each individual +configuration. + +### Appearance +`SiteCompanion` comes with the following appearance customization +options that can be inherited: +1. Theme: choose the theme of the extension popup and the toolbar. + The settings UI will use the global theme. +2. Toolbar Position: choose where the toolbar appears on known sites. +3. Always Use Default Env/Profile: enabling this option will disable + the environment and profile selection fields in the popup UI. +4. Empty Toolbar: let the toolbar hide or show a button called "Open + SiteCompanion" when the toolbar is empty in the current site. + +##### Global-Only Appearance Configurations +Some configurations are only applicable globally. + +1. Auto-hide toolbar on unknown sites: Turning on this option will + hide the toolbar when the current site is not known by + `SiteCompanion`. +2. Always show output buffer in popup UI: Turning this off will show + the output buffer even when the site is unknown. + +### API Keys (Global Only) +The keys for authenticating with the AI service provider. + +### API Configurations +Configures the details of AI service endpoints: +1. API Key: which API key to use. +2. API (Base) URL: what the URL to the AI service endpoint is. +3. Model Name: what model to use. + +*Workspaces and sites may only inherit API configurations of their +parents, and never create their own.* + +#### Advanced Mode +To accommodate different API request formats, `SiteCompanion` offers +"Advanced Mode" for a more customizable experience. + +In "Advanced Mode", you may directly edit the API endpoint URL and the +`JSON` template for requests. + +### Environments +Environments are bundles of API configuration and a system prompt. +They define the environment in which the task is running. + +An environment contains the following components: +1. API configuration: select the API configuration to use for this + environment. +2. System prompt: the piece of prompt that defines the profile of the + model. + +### Profiles +User profiles to give more context to the model about **who you are**. + +### Tasks +Prompts that tell the model **what to do**. + +#### Task Defaults +A task will be associated with a default environment and profile +combination for quick access. + +Of course, you may choose whichever environment and profile you want +to use in the popup UI. + +### Toolbar Shortcuts +Configures the predefined shortcuts that appear on the toolbar. + +Each shortcut is a combination of environment + profile + task. + +### Site-Specific Configurations +Some configurations are site-specific. They are there to configure how +`SiteCompanion` recognizes a site, the workspace the site belongs to, +and how it extracts text from it. + +The additional configuration options are: +1. URL Pattern: the globbing pattern to match URLs against for that + site configuration. +2. Workspace: the workspace that the site belongs to. +3. Site Text Selector: the configuration for the pattern used by + `SiteCompanion` to extract the text from the site. + +#### Selector Patterns +The supported selector patterns are: + * `{ kind: "css"; selector: string }` + * `{ kind: "cssAll"; selector: string; index: number }` + * `{ kind: "textScope"; text: string }` + * `{ kind: "anchoredCss"; anchor: { kind: "textScope"; text: string }; selector: string }` + + +## Planned Features +Nothing at the moment! + +Send a feature request to **Issues** if you'd like to request a new +feature! + +## Known Bugs +Nothing at the moment! + +Send a bug report to **Issues** if you find one! + +## Disclaimer +1. The extension is not responsible for your actions. **YOU** must be + responsible for your own actions. +2. `SiteCompanion` is free and will always be free; no party should + offer paid services for `SiteCompanion` users. +3. If a branch/fork diverges from the design of `SiteCompanion`, it + may not use its branding nor will it be merged into the official + version. +4. No donations or other monetization will affect feature requests or + bug fix priorities. diff --git a/sitecompanion/background.js b/sitecompanion/background.js index 0732851..b9d32b6 100644 --- a/sitecompanion/background.js +++ b/sitecompanion/background.js @@ -17,6 +17,8 @@ const DEFAULT_SETTINGS = { theme: "system", toolbarAutoHide: true, alwaysShowOutput: false, + alwaysUseDefaultEnvProfile: false, + emptyToolbarBehavior: "open", workspaces: [] }; diff --git a/sitecompanion/content.js b/sitecompanion/content.js index ef3371d..e414d5e 100644 --- a/sitecompanion/content.js +++ b/sitecompanion/content.js @@ -360,6 +360,22 @@ function resolveThemeValue(globalTheme, workspace, site) { return globalTheme || "system"; } +function normalizeEmptyToolbarBehavior(value, allowInherit = true) { + if (value === "hide" || value === "open") return value; + if (allowInherit && value === "inherit") return "inherit"; + return allowInherit ? "inherit" : "open"; +} + +function resolveEmptyToolbarBehavior(globalValue, workspace, site) { + const base = normalizeEmptyToolbarBehavior(globalValue, false); + const workspaceValue = normalizeEmptyToolbarBehavior( + workspace?.emptyToolbarBehavior + ); + const workspaceResolved = workspaceValue === "inherit" ? base : workspaceValue; + const siteValue = normalizeEmptyToolbarBehavior(site?.emptyToolbarBehavior); + return siteValue === "inherit" ? workspaceResolved : siteValue; +} + function resolveThemeMode(theme) { if (theme === "dark" || theme === "light") return theme; if (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) { @@ -393,10 +409,20 @@ function getToolbarThemeTokens(mode) { }; } -function createToolbar(shortcuts, position = "bottom-right", themeMode = "light", options = {}) { +function createToolbar( + shortcuts, + position = "bottom-right", + themeMode = "light", + options = {} +) { let toolbar = document.getElementById("sitecompanion-toolbar"); if (toolbar) toolbar.remove(); + const hasShortcuts = Array.isArray(shortcuts) && shortcuts.length > 0; + const showOpenButton = + options?.unknown || (!hasShortcuts && options?.emptyBehavior === "open"); + if (!hasShortcuts && !showOpenButton) return; + toolbar = document.createElement("div"); toolbar.id = "sitecompanion-toolbar"; @@ -437,7 +463,7 @@ function createToolbar(shortcuts, position = "bottom-right", themeMode = "light" color: ${tokens.ink}; `; - if (options?.unknown) { + if (showOpenButton) { const btn = document.createElement("button"); btn.type = "button"; btn.textContent = "Open SiteCompanion"; @@ -457,12 +483,6 @@ function createToolbar(shortcuts, position = "bottom-right", themeMode = "light" }); }); toolbar.appendChild(btn); - } else if (!shortcuts || !shortcuts.length) { - const label = document.createElement("span"); - label.textContent = "SiteCompanion"; - label.style.fontSize = "12px"; - label.style.color = tokens.muted; - toolbar.appendChild(label); } else { for (const shortcut of shortcuts) { const btn = document.createElement("button"); @@ -527,7 +547,8 @@ async function refreshToolbar() { presets = [], toolbarPosition = "bottom-right", theme = "system", - toolbarAutoHide = true + toolbarAutoHide = true, + emptyToolbarBehavior = "open" } = await chrome.storage.local.get([ "sites", "workspaces", @@ -535,7 +556,8 @@ async function refreshToolbar() { "presets", "toolbarPosition", "theme", - "toolbarAutoHide" + "toolbarAutoHide", + "emptyToolbarBehavior" ]); const currentUrl = window.location.href; const site = sites.find(s => matchUrl(currentUrl, s.urlPattern)); @@ -579,7 +601,19 @@ async function refreshToolbar() { : toolbarPosition; const resolvedTheme = resolveThemeValue(theme, workspace, site); const themeMode = resolveThemeMode(resolvedTheme); - createToolbar(siteShortcuts, resolvedPosition, themeMode); + const resolvedEmptyToolbarBehavior = resolveEmptyToolbarBehavior( + emptyToolbarBehavior, + workspace, + site + ); + if (!siteShortcuts.length && resolvedEmptyToolbarBehavior === "hide") { + const toolbar = document.getElementById("sitecompanion-toolbar"); + if (toolbar) toolbar.remove(); + return; + } + createToolbar(siteShortcuts, resolvedPosition, themeMode, { + emptyBehavior: resolvedEmptyToolbarBehavior + }); } catch (error) { const message = String(error?.message || ""); if (message.includes("Extension context invalidated")) { @@ -596,6 +630,7 @@ async function refreshToolbar() { let refreshTimer = null; +let contentChangeTimer = null; function scheduleToolbarRefresh() { if (refreshTimer) return; refreshTimer = window.setTimeout(() => { @@ -610,9 +645,22 @@ function scheduleToolbarRefresh() { }, 200); } +function scheduleContentChangeNotice() { + if (contentChangeTimer) return; + contentChangeTimer = window.setTimeout(() => { + contentChangeTimer = null; + chrome.runtime.sendMessage({ type: "SITE_CONTENT_CHANGED" }, () => { + if (chrome.runtime.lastError) { + return; + } + }); + }, 250); +} + const observer = new MutationObserver(() => { if (suppressObserver) return; scheduleToolbarRefresh(); + scheduleContentChangeNotice(); }); observer.observe(document.documentElement, { childList: true, subtree: true }); diff --git a/sitecompanion/icon128.png b/sitecompanion/icon128.png new file mode 100644 index 0000000..aba2554 Binary files /dev/null and b/sitecompanion/icon128.png differ diff --git a/sitecompanion/manifest.json b/sitecompanion/manifest.json index 632cb92..3361c12 100644 --- a/sitecompanion/manifest.json +++ b/sitecompanion/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "SiteCompanion", - "version": "0.4.7", + "version": "0.4.8", "description": "AI companion for site-bound text extraction and tasks.", "permissions": ["storage", "activeTab"], "host_permissions": [""], @@ -22,5 +22,8 @@ "options_ui": { "page": "settings.html", "open_in_tab": true + }, + "icons": { + "128": "icon128.png" } } diff --git a/sitecompanion/popup.css b/sitecompanion/popup.css index f35dba3..a5c9f4b 100644 --- a/sitecompanion/popup.css +++ b/sitecompanion/popup.css @@ -47,6 +47,9 @@ body { font-family: system-ui, -apple-system, "Segoe UI", sans-serif; color: var(--ink); background: var(--page-bg); + --control-height: 30px; + --output-max-height-base: 276px; + --output-height-delta: 0px; } .title-block { @@ -119,7 +122,8 @@ label { select { width: 100%; - padding: 6px 8px; + height: var(--control-height); + padding: 0 8px; border-radius: 10px; border: 1px solid var(--border); background: var(--input-bg); @@ -138,6 +142,49 @@ select { margin: 0; } +.env-profile-summary { + display: none; + align-items: center; + gap: 8px; + font-size: 11px; + color: var(--muted); + margin-bottom: 8px; + font-style: italic; +} + +.env-profile-item { + flex: 1; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +body.always-default-env-profile .selector-row { + display: none; +} + +body.always-default-env-profile .env-profile-summary { + display: flex; + margin-bottom: 8px; +} + +body.always-default-env-profile:not(.custom-task-mode) .config-block { + row-gap: 0; +} + +body.always-default-env-profile:not(.custom-task-mode) { + --output-max-height-base: 309px; +} + +body.custom-task-mode .env-profile-summary { + display: none; +} + +body.custom-task-mode.always-default-env-profile .selector-row { + display: flex; +} + .task-row { display: flex; align-items: flex-end; @@ -145,7 +192,7 @@ select { } .task-row button { - padding: 6px 15px; + padding: 0 15px; } .task-row .task-field { @@ -157,6 +204,37 @@ select { min-width: 0; } +.custom-task-row { + align-items: stretch; +} + +.custom-task-field { + flex: 1; + margin: 0; +} + +.custom-task-field textarea { + 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; + min-height: 52px; +} + +.custom-task-actions { + display: flex; + flex-direction: column; + gap: 6px; +} + +.custom-task-actions button { + width: 100%; +} + .hidden { display: none !important; } @@ -221,11 +299,18 @@ button { font-family: inherit; border: none; border-radius: 10px; - padding: 6px 10px; + padding: 0 10px; cursor: pointer; transition: transform 0.15s ease, box-shadow 0.15s ease; } +button.control-btn { + height: var(--control-height); + display: inline-flex; + align-items: center; + justify-content: center; +} + button:disabled { opacity: 0.5; cursor: not-allowed; @@ -287,14 +372,14 @@ button:active { padding: 8px; background: var(--output-bg); min-height: 210px; - max-height: 280px; + max-height: calc(var(--output-max-height-base) - var(--output-height-delta)); overflow: hidden; } .output-body { margin: 0; word-break: break-word; - max-height: 260px; + max-height: calc(var(--output-max-height-base) - var(--output-height-delta) - 20px); overflow-y: auto; font-size: 11px; line-height: 1.45; @@ -384,6 +469,10 @@ button:active { gap: 8px; } +.footer.compact { + justify-content: flex-end; +} + .footer-left { display: flex; align-items: center; diff --git a/sitecompanion/popup.html b/sitecompanion/popup.html index 4ff041c..3698a9d 100644 --- a/sitecompanion/popup.html +++ b/sitecompanion/popup.html @@ -20,8 +20,8 @@
- - + +
@@ -43,8 +43,8 @@
- - + +
@@ -65,19 +65,45 @@ -
+
+
+ ENV: +
+
+ PROFILE: +
+
+
- - + +
+ + +
+
+
Site Text: 0 chars - Task: 0 chars + Total: 0 chars Idle
@@ -88,9 +114,15 @@ diff --git a/sitecompanion/popup.js b/sitecompanion/popup.js index f114042..a388a7b 100644 --- a/sitecompanion/popup.js +++ b/sitecompanion/popup.js @@ -3,6 +3,16 @@ const abortBtn = document.getElementById("abortBtn"); const taskSelect = document.getElementById("taskSelect"); const envSelect = document.getElementById("envSelect"); const profileSelect = document.getElementById("profileSelect"); +const envProfileSummary = document.getElementById("envProfileSummary"); +const envSummaryValue = document.getElementById("envSummaryValue"); +const profileSummaryValue = document.getElementById("profileSummaryValue"); +const customTaskBtn = document.getElementById("customTaskBtn"); +const normalTaskBtn = document.getElementById("normalTaskBtn"); +const customTaskInput = document.getElementById("customTaskInput"); +const normalTaskRow = document.getElementById("normalTaskRow"); +const customTaskRow = document.getElementById("customTaskRow"); +const taskActions = document.getElementById("taskActions"); +const taskActionsSlot = document.getElementById("taskActionsSlot"); const outputEl = document.getElementById("output"); const statusEl = document.getElementById("status"); const postingCountEl = document.getElementById("postingCount"); @@ -12,6 +22,8 @@ const copyRenderedBtn = document.getElementById("copyRenderedBtn"); const copyRawBtn = document.getElementById("copyRawBtn"); const clearOutputBtn = document.getElementById("clearOutputBtn"); const outputSection = document.querySelector(".output"); +const footerLeft = document.querySelector(".footer-left"); +const footer = document.querySelector(".footer"); const OUTPUT_STORAGE_KEY = "lastOutput"; const AUTO_RUN_KEY = "autoRunDefaultTask"; @@ -20,6 +32,8 @@ const LAST_TASK_KEY = "lastSelectedTaskId"; const LAST_ENV_KEY = "lastSelectedEnvId"; const LAST_PROFILE_KEY = "lastSelectedProfileId"; const POPUP_DRAFT_KEY = "popupDraft"; +const CUSTOM_TASK_MODE_KEY = "customTaskMode"; +const CUSTOM_TASK_TEXT_KEY = "customTaskText"; const unknownSiteState = document.getElementById("unknownSiteState"); const extractionReviewState = document.getElementById("extractionReviewState"); @@ -57,7 +71,12 @@ const state = { selectedTaskId: "", selectedEnvId: "", selectedProfileId: "", - alwaysShowOutput: false + alwaysShowOutput: false, + alwaysUseDefaultEnvProfile: false, + activeTabId: null, + pendingConfigRefresh: false, + customTaskMode: false, + customTaskText: "" }; async function switchState(stateName) { @@ -118,6 +137,7 @@ function applyPopupDraft(draft) { } else if (typeof draft.siteTextSelector === "string") { state.siteTextTarget = { kind: "css", selector: draft.siteTextSelector }; } + updateCounts(); } function matchUrl(url, pattern) { @@ -668,6 +688,46 @@ function resolveThemeForPopup(baseTheme) { return baseTheme || "system"; } +function resolveAppearanceToggleValue(value, fallback) { + if (value === "enabled") return true; + if (value === "disabled") return false; + if (value === "inherit" || value === null || value === undefined) { + return Boolean(fallback); + } + if (typeof value === "boolean") return value; + return Boolean(fallback); +} + +function resolveAlwaysUseDefaultEnvProfile(baseSetting, workspace, site) { + const resolvedBase = resolveAppearanceToggleValue(baseSetting, false); + const workspaceResolved = resolveAppearanceToggleValue( + workspace?.alwaysUseDefaultEnvProfile, + resolvedBase + ); + return resolveAppearanceToggleValue( + site?.alwaysUseDefaultEnvProfile, + workspaceResolved + ); +} + +function updateEnvProfileSummary() { + if (!envSummaryValue || !profileSummaryValue) return; + const env = getSelectedEnv(); + const profile = getSelectedProfile(); + envSummaryValue.textContent = env ? env.name || "Default" : "None"; + profileSummaryValue.textContent = profile ? profile.name || "Default" : "None"; +} + +function applyAlwaysUseDefaultEnvProfileState() { + document.body.classList.toggle( + "always-default-env-profile", + state.alwaysUseDefaultEnvProfile + ); + updateEnvSelectState(); + updateProfileSelectState(); + updateEnvProfileSummary(); +} + function setAnalyzing(isAnalyzing) { state.isAnalyzing = isAnalyzing; runBtn.disabled = isAnalyzing; @@ -677,6 +737,10 @@ function setAnalyzing(isAnalyzing) { updateTaskSelectState(); updateEnvSelectState(); updateProfileSelectState(); + if (!isAnalyzing && state.pendingConfigRefresh) { + state.pendingConfigRefresh = false; + scheduleConfigRefresh(); + } } function updateOutputVisibility() { @@ -684,14 +748,175 @@ function updateOutputVisibility() { const shouldHide = state.currentPopupState !== "normal" && !state.alwaysShowOutput; outputSection.classList.toggle("hidden", shouldHide); + footerLeft?.classList.toggle("hidden", shouldHide); + footer?.classList.toggle("compact", shouldHide); +} + +async function persistCustomTaskState() { + await chrome.storage.local.set({ + [CUSTOM_TASK_MODE_KEY]: state.customTaskMode, + [CUSTOM_TASK_TEXT_KEY]: state.customTaskText + }); +} + +function setCustomTaskMode(enabled, { persist = true } = {}) { + state.customTaskMode = Boolean(enabled); + document.body.classList.toggle("custom-task-mode", state.customTaskMode); + if (state.customTaskMode) { + if (normalTaskRow) { + const measured = measureRowHeight(normalTaskRow); + if (measured) normalTaskRowHeight = measured; + normalTaskRow.classList.add("hidden"); + } + customTaskRow?.classList.remove("hidden"); + if (taskActionsSlot && taskActions) { + taskActionsSlot.appendChild(taskActions); + } + if (customTaskInput) { + customTaskInput.value = state.customTaskText || ""; + customTaskInput.focus(); + } + window.requestAnimationFrame(() => { + if (customTaskRow) { + const measured = measureRowHeight(customTaskRow); + if (measured) customTaskRowHeight = measured; + } + updateOutputHeightDelta(); + }); + } else { + customTaskRow?.classList.add("hidden"); + if (normalTaskRow) { + normalTaskRow.classList.remove("hidden"); + } + if (normalTaskRow && taskActions) { + normalTaskRow.appendChild(taskActions); + } + window.requestAnimationFrame(() => { + if (normalTaskRow) { + const measured = measureRowHeight(normalTaskRow); + if (measured) normalTaskRowHeight = measured; + } + updateOutputHeightDelta(); + }); + } + updatePromptCount(); + updateEnvSelectState(); + updateProfileSelectState(); + if (persist) { + void persistCustomTaskState(); + } +} + +function getSelectedTask() { + if (state.forcedTask) return state.forcedTask; + const selectedId = taskSelect?.value || state.selectedTaskId; + return state.tasks.find((item) => item.id === selectedId) || state.tasks[0] || null; +} + +function getSelectedProfile() { + const selectedId = profileSelect?.value || state.selectedProfileId; + return ( + state.profiles.find((item) => item.id === selectedId) || + state.profiles[0] || + null + ); +} + +function getSelectedEnv() { + const selectedId = envSelect?.value || state.selectedEnvId; + return state.envs.find((item) => item.id === selectedId) || state.envs[0] || null; +} + +function buildTotalPromptText() { + const task = getSelectedTask(); + const profile = getSelectedProfile(); + const env = getSelectedEnv(); + const systemPrompt = env?.systemPrompt || ""; + const customText = (state.customTaskText || "").trim(); + const taskText = + state.customTaskMode && !state.forcedTask ? customText : task?.text || ""; + const userPrompt = buildUserMessage( + profile?.text || "", + taskText, + state.siteText || "" + ); + return systemPrompt ? `${systemPrompt}\n\n${userPrompt}` : userPrompt; } function updateSiteTextCount() { - postingCountEl.textContent = `Site Text: ${state.siteText.length} chars`; + const length = (state.siteText || "").length; + postingCountEl.textContent = `Site Text: ${length} chars`; } function updatePromptCount(count) { - promptCountEl.textContent = `Task: ${count} chars`; + const total = + typeof count === "number" ? count : buildTotalPromptText().length; + promptCountEl.textContent = `Total: ${total} chars`; +} + +function updateCounts() { + updateSiteTextCount(); + updatePromptCount(); +} + +let siteContentRefreshTimer = null; +function scheduleSiteContentRefresh() { + if (siteContentRefreshTimer) return; + siteContentRefreshTimer = window.setTimeout(() => { + siteContentRefreshTimer = null; + void refreshSiteContentCounts(); + }, 250); +} + +let configRefreshTimer = null; +function scheduleConfigRefresh() { + if (state.isAnalyzing) { + state.pendingConfigRefresh = true; + return; + } + if (configRefreshTimer) return; + configRefreshTimer = window.setTimeout(() => { + configRefreshTimer = null; + void loadConfig(); + }, 250); +} + +let normalTaskRowHeight = null; +let customTaskRowHeight = null; + +function measureRowHeight(row) { + if (!row) return 0; + return row.getBoundingClientRect().height || 0; +} + +function updateOutputHeightDelta() { + const baseHeight = normalTaskRowHeight || measureRowHeight(normalTaskRow); + if (!baseHeight) return; + if (!state.customTaskMode) { + document.body.style.setProperty("--output-height-delta", "0px"); + return; + } + const customHeight = customTaskRowHeight || measureRowHeight(customTaskRow); + const delta = Math.max(0, customHeight - baseHeight); + document.body.style.setProperty("--output-height-delta", `${Math.round(delta)}px`); +} + +async function refreshSiteContentCounts() { + if (state.isAnalyzing) return; + if (state.currentPopupState !== "normal") return; + if (!state.siteTextTarget) return; + try { + const response = await sendToActiveTab({ + type: "EXTRACT_BY_SELECTOR", + target: state.siteTextTarget + }); + if (!response?.ok) return; + state.siteText = response.extracted || ""; + state.siteTextTarget = response.target || state.siteTextTarget; + updateCounts(); + } catch { + // Ignore refresh failures; counts will update on next explicit extract. + } } function renderTasks(tasks) { @@ -726,6 +951,7 @@ function renderEnvironments(envs) { option.value = ""; envSelect.appendChild(option); updateEnvSelectState(); + updateEnvProfileSummary(); return; } @@ -736,6 +962,7 @@ function renderEnvironments(envs) { envSelect.appendChild(option); } updateEnvSelectState(); + updateEnvProfileSummary(); } function updateTaskSelectState() { @@ -745,7 +972,10 @@ function updateTaskSelectState() { function updateEnvSelectState() { const hasEnvs = state.envs.length > 0; - envSelect.disabled = state.isAnalyzing || !hasEnvs; + envSelect.disabled = + state.isAnalyzing || + !hasEnvs || + (state.alwaysUseDefaultEnvProfile && !state.customTaskMode); } function renderProfiles(profiles) { @@ -758,6 +988,7 @@ function renderProfiles(profiles) { option.value = ""; profileSelect.appendChild(option); updateProfileSelectState(); + updateEnvProfileSummary(); return; } @@ -768,11 +999,15 @@ function renderProfiles(profiles) { profileSelect.appendChild(option); } updateProfileSelectState(); + updateEnvProfileSummary(); } function updateProfileSelectState() { const hasProfiles = state.profiles.length > 0; - profileSelect.disabled = state.isAnalyzing || !hasProfiles; + profileSelect.disabled = + state.isAnalyzing || + !hasProfiles || + (state.alwaysUseDefaultEnvProfile && !state.customTaskMode); } function getTaskDefaultEnvId(task) { @@ -792,6 +1027,8 @@ function setEnvironmentSelection(envId) { envSelect.value = target; } state.selectedEnvId = target; + updatePromptCount(); + updateEnvProfileSummary(); } function setProfileSelection(profileId) { @@ -803,6 +1040,8 @@ function setProfileSelection(profileId) { profileSelect.value = target; } state.selectedProfileId = target; + updatePromptCount(); + updateEnvProfileSummary(); } function selectTask(taskId, { resetEnv } = { resetEnv: false }) { @@ -814,6 +1053,7 @@ function selectTask(taskId, { resetEnv } = { resetEnv: false }) { setEnvironmentSelection(getTaskDefaultEnvId(task)); setProfileSelection(getTaskDefaultProfileId(task)); } + updatePromptCount(); } async function persistSelections() { @@ -900,6 +1140,7 @@ function ensurePort() { async function loadConfig() { const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); const currentUrl = tabs[0]?.url || ""; + state.activeTabId = tabs[0]?.id || null; const { lastPopupState, [POPUP_DRAFT_KEY]: popupDraft } = await getStorage([ "lastPopupState", @@ -926,9 +1167,12 @@ async function loadConfig() { "sites", "theme", "alwaysShowOutput", + "alwaysUseDefaultEnvProfile", LAST_TASK_KEY, LAST_ENV_KEY, - LAST_PROFILE_KEY + LAST_PROFILE_KEY, + CUSTOM_TASK_MODE_KEY, + CUSTOM_TASK_TEXT_KEY ]); const tasks = normalizeConfigList(stored.tasks); const envs = normalizeConfigList(stored.envConfigs); @@ -968,12 +1212,23 @@ async function loadConfig() { state.currentWorkspace = activeWorkspace; currentWorkspaceName.textContent = activeWorkspace.name || "Global"; } + if (state.currentSite && !state.siteTextTarget) { + state.siteTextTarget = normalizeStoredExtractTarget(state.currentSite); + } if (stored.theme) { state.globalTheme = stored.theme; } state.alwaysShowOutput = Boolean(stored.alwaysShowOutput); + state.alwaysUseDefaultEnvProfile = resolveAlwaysUseDefaultEnvProfile( + stored.alwaysUseDefaultEnvProfile, + activeWorkspace, + activeSite + ); applyTheme(resolveThemeForPopup(state.globalTheme)); updateOutputVisibility(); + applyAlwaysUseDefaultEnvProfileState(); + state.customTaskMode = Boolean(stored[CUSTOM_TASK_MODE_KEY]); + state.customTaskText = stored[CUSTOM_TASK_TEXT_KEY] || ""; const effectiveEnvs = resolveEffectiveList( envs, @@ -1000,11 +1255,16 @@ async function loadConfig() { renderTasks(effectiveTasks); renderEnvironments(effectiveEnvs); renderProfiles(effectiveProfiles); + if (customTaskInput) { + customTaskInput.value = state.customTaskText; + } + setCustomTaskMode(state.customTaskMode, { persist: false }); if (!effectiveTasks.length) { state.selectedTaskId = ""; setEnvironmentSelection(effectiveEnvs[0]?.id || ""); setProfileSelection(effectiveProfiles[0]?.id || ""); + updateCounts(); return; } @@ -1014,22 +1274,24 @@ async function loadConfig() { const initialTaskId = effectiveTasks.some((task) => task.id === storedTaskId) ? storedTaskId : effectiveTasks[0].id; - selectTask(initialTaskId, { resetEnv: false }); + selectTask(initialTaskId, { resetEnv: state.alwaysUseDefaultEnvProfile }); const task = effectiveTasks.find((item) => item.id === initialTaskId); - if (storedEnvId && effectiveEnvs.some((env) => env.id === storedEnvId)) { - setEnvironmentSelection(storedEnvId); - } else { - setEnvironmentSelection(getTaskDefaultEnvId(task)); - } + if (!state.alwaysUseDefaultEnvProfile) { + if (storedEnvId && effectiveEnvs.some((env) => env.id === storedEnvId)) { + setEnvironmentSelection(storedEnvId); + } else { + setEnvironmentSelection(getTaskDefaultEnvId(task)); + } - if ( - storedProfileId && - effectiveProfiles.some((profile) => profile.id === storedProfileId) - ) { - setProfileSelection(storedProfileId); - } else { - setProfileSelection(getTaskDefaultProfileId(task)); + if ( + storedProfileId && + effectiveProfiles.some((profile) => profile.id === storedProfileId) + ) { + setProfileSelection(storedProfileId); + } else { + setProfileSelection(getTaskDefaultProfileId(task)); + } } if ( @@ -1040,6 +1302,10 @@ async function loadConfig() { await persistSelections(); } + updateCounts(); + if (state.currentSite) { + await refreshSiteContentCounts(); + } maybeRunDefaultTask(); } @@ -1068,8 +1334,7 @@ async function handleExtract() { state.siteText = response.extracted || ""; state.siteTextTarget = response.target || target; - updateSiteTextCount(); - updatePromptCount(0); + updateCounts(); setStatus("Text extracted."); return true; } catch (error) { @@ -1094,13 +1359,18 @@ async function handleAnalyze() { const taskId = taskSelect.value; const forcedTask = state.forcedTask; const task = forcedTask || state.tasks.find((item) => item.id === taskId); + const useCustomTask = state.customTaskMode && !forcedTask; if (forcedTask) { state.forcedTask = null; } - if (!task) { + if (!useCustomTask && !task) { setStatus("Select a task."); return; } + if (state.alwaysUseDefaultEnvProfile && !forcedTask && !state.customTaskMode) { + setEnvironmentSelection(getTaskDefaultEnvId(task)); + setProfileSelection(getTaskDefaultProfileId(task)); + } const { apiKeys = [], @@ -1139,6 +1409,12 @@ async function handleAnalyze() { } const resolvedSystemPrompt = activeEnv.systemPrompt ?? systemPrompt ?? ""; + const customTaskText = (state.customTaskText || "").trim(); + const resolvedTaskText = useCustomTask ? customTaskText : task?.text || ""; + if (useCustomTask && !resolvedTaskText) { + setStatus("Enter a custom task."); + return; + } const resolvedApiConfigId = activeEnv.apiConfigId || activeApiConfigId || resolvedConfigs[0]?.id || ""; const activeConfig = @@ -1194,12 +1470,7 @@ async function handleAnalyze() { } } - const promptText = buildUserMessage( - profileText, - task.text || "", - state.siteText - ); - updatePromptCount(promptText.length); + updatePromptCount(); state.outputRaw = ""; renderOutput(); @@ -1220,7 +1491,7 @@ async function handleAnalyze() { model: resolvedModel, systemPrompt: resolvedSystemPrompt, profileText, - taskText: task.text || "", + taskText: resolvedTaskText, siteText: state.siteText, tabId: tab.id } @@ -1298,6 +1569,7 @@ async function runMinimalExtraction(text, minLength = 5) { state.siteText = response.extracted; state.siteTextTarget = response.target || { kind: "textScope", text: trimmed }; extractedPreview.textContent = state.siteText; + updateCounts(); await fillSiteDefaultsFromTab(); switchState("review"); await persistPopupDraft(); @@ -1336,6 +1608,7 @@ extractFullBtn.addEventListener("click", async () => { state.siteText = response.extracted; state.siteTextTarget = target; extractedPreview.textContent = state.siteText; + updateCounts(); await fillSiteDefaultsFromTab(); switchState("review"); await persistPopupDraft(); @@ -1372,6 +1645,7 @@ retryExtractBtn.addEventListener("click", () => { if (workspaceSelect) workspaceSelect.value = "global"; state.siteText = ""; state.siteTextTarget = null; + updateCounts(); setMinimalStatus(""); void clearPopupDraft(); setStatus("Ready."); @@ -1427,10 +1701,24 @@ confirmSiteBtn.addEventListener("click", async () => { currentWorkspaceName.textContent = state.currentWorkspace.name || "Global"; await loadConfig(); await switchState("normal"); - updateSiteTextCount(); + updateCounts(); setStatus("Site saved."); }); +customTaskBtn?.addEventListener("click", () => { + setCustomTaskMode(true); +}); + +normalTaskBtn?.addEventListener("click", () => { + setCustomTaskMode(false); +}); + +customTaskInput?.addEventListener("input", () => { + state.customTaskText = customTaskInput.value || ""; + updatePromptCount(); + void persistCustomTaskState(); +}); + runBtn.addEventListener("click", handleExtractAndAnalyze); abortBtn.addEventListener("click", handleAbort); settingsBtn.addEventListener("click", () => chrome.runtime.openOptionsPage()); @@ -1450,8 +1738,7 @@ profileSelect.addEventListener("change", () => { void persistSelections(); }); -updateSiteTextCount(); -updatePromptCount(0); +updateCounts(); renderOutput(); setAnalyzing(false); void loadTheme(); @@ -1475,6 +1762,7 @@ async function loadShortcutRunRequest() { state.shortcutRunPending = true; await chrome.storage.local.remove(SHORTCUT_RUN_KEY); + setCustomTaskMode(false); if (!state.tasks.length) { await loadConfig(); @@ -1538,7 +1826,13 @@ async function loadShortcutRunRequest() { await persistSelections(); state.autoRunPending = false; state.shortcutRunPending = false; - void handleExtractAndAnalyze(); + void handleExtractAndAnalyze().finally(() => { + if (!state.alwaysUseDefaultEnvProfile) return; + const selectedTask = getSelectedTask(); + if (!selectedTask) return; + setEnvironmentSelection(getTaskDefaultEnvId(selectedTask)); + setProfileSelection(getTaskDefaultProfileId(selectedTask)); + }); } async function loadAutoRunRequest() { @@ -1597,4 +1891,28 @@ chrome.storage.onChanged.addListener((changes) => { state.alwaysShowOutput = Boolean(changes.alwaysShowOutput.newValue); updateOutputVisibility(); } + + const configKeys = [ + "tasks", + "envConfigs", + "profiles", + "shortcuts", + "workspaces", + "sites", + "theme", + "alwaysShowOutput", + "alwaysUseDefaultEnvProfile" + ]; + if (configKeys.some((key) => changes[key])) { + scheduleConfigRefresh(); + } +}); + +chrome.runtime.onMessage.addListener((message, sender) => { + if (message?.type !== "SITE_CONTENT_CHANGED") return; + const senderTabId = sender?.tab?.id || null; + if (state.activeTabId && senderTabId && senderTabId !== state.activeTabId) { + return; + } + scheduleSiteContentRefresh(); }); diff --git a/sitecompanion/settings.css b/sitecompanion/settings.css index 498d13e..d7c9cb7 100644 --- a/sitecompanion/settings.css +++ b/sitecompanion/settings.css @@ -749,6 +749,10 @@ button:active { grid-template-columns: repeat(3, minmax(0, 1fr)); } +.inline-fields.four { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + .inline-fields .field { margin-bottom: 0; } diff --git a/sitecompanion/settings.html b/sitecompanion/settings.html index b00c1f8..0d30a3c 100644 --- a/sitecompanion/settings.html +++ b/sitecompanion/settings.html @@ -103,7 +103,7 @@
-
+
+
+ + +
+
+ + +
diff --git a/sitecompanion/settings.js b/sitecompanion/settings.js index 1e9689b..8203205 100644 --- a/sitecompanion/settings.js +++ b/sitecompanion/settings.js @@ -19,8 +19,14 @@ const statusSidebarEl = document.getElementById("statusSidebar"); const sidebarErrorsEl = document.getElementById("sidebarErrors"); const themeSelect = document.getElementById("themeSelect"); const toolbarPositionSelect = document.getElementById("toolbarPositionSelect"); +const emptyToolbarBehaviorSelect = document.getElementById( + "emptyToolbarBehaviorSelect" +); const toolbarAutoHide = document.getElementById("toolbarAutoHide"); const alwaysShowOutput = document.getElementById("alwaysShowOutput"); +const alwaysUseDefaultEnvProfileSelect = document.getElementById( + "alwaysUseDefaultEnvProfileSelect" +); const globalSitesContainer = document.getElementById("globalSites"); const toc = document.querySelector(".toc"); const tocResizer = document.getElementById("tocResizer"); @@ -453,8 +459,14 @@ function buildSettingsSnapshot() { toolbarPosition: toolbarPositionSelect ? toolbarPositionSelect.value : "bottom-right", + emptyToolbarBehavior: emptyToolbarBehaviorSelect + ? emptyToolbarBehaviorSelect.value + : "open", toolbarAutoHide: toolbarAutoHide ? toolbarAutoHide.checked : true, - alwaysShowOutput: alwaysShowOutput ? alwaysShowOutput.checked : false + alwaysShowOutput: alwaysShowOutput ? alwaysShowOutput.checked : false, + alwaysUseDefaultEnvProfile: alwaysUseDefaultEnvProfileSelect + ? alwaysUseDefaultEnvProfileSelect.value === "enabled" + : false }); } @@ -519,6 +531,35 @@ function scheduleSidebarErrors() { }); } +let tocUpdateFrame = null; +function scheduleTocUpdate() { + if (!toc) return; + if (tocUpdateFrame) return; + tocUpdateFrame = requestAnimationFrame(() => { + tocUpdateFrame = null; + updateToc(collectWorkspaces(), collectSites()); + }); +} + +const TOC_NAME_INPUT_SELECTOR = [ + ".api-key-name", + ".api-config-name", + ".env-config-name", + ".profile-name", + ".task-name", + ".shortcut-name", + ".workspace-name", + ".site-name", + ".site-pattern" +].join(", "); + +function handleTocNameInput(event) { + const target = event.target; + if (!(target instanceof Element)) return; + if (!target.matches(TOC_NAME_INPUT_SELECTOR)) return; + scheduleTocUpdate(); +} + function renderGlobalSitesList(sites) { if (!globalSitesContainer) return; globalSitesContainer.innerHTML = ""; @@ -1230,6 +1271,7 @@ function updateApiConfigControls() { if (moveDownBtn) moveDownBtn.disabled = index === cards.length - 1; }); scheduleSidebarErrors(); + scheduleTocUpdate(); } function buildApiKeyCard(entry) { @@ -1404,6 +1446,7 @@ function updateApiKeyControls() { if (moveDownBtn) moveDownBtn.disabled = index === cards.length - 1; }); scheduleSidebarErrors(); + scheduleTocUpdate(); } function updateApiConfigKeyOptions() { @@ -1630,6 +1673,7 @@ function updateEnvControls(container = envConfigsContainer) { if (moveDownBtn) moveDownBtn.disabled = index === cards.length - 1; }); scheduleSidebarErrors(); + scheduleTocUpdate(); } function updateTaskEnvOptionsForContainer(container, envs, allEnvsById) { @@ -1879,6 +1923,7 @@ function updateProfileControls(container = profilesContainer) { if (moveDownBtn) moveDownBtn.disabled = index === cards.length - 1; }); scheduleSidebarErrors(); + scheduleTocUpdate(); } function updateTaskProfileOptionsForContainer(container, profiles, allProfilesById) { @@ -1952,7 +1997,9 @@ function updateEnvApiOptionsForContainer(container, apiConfigs) { if (!container) return; const selects = container.querySelectorAll(".env-config-api-select"); selects.forEach((select) => { - select.dataset.preferred = select.value; + if (select.value) { + select.dataset.preferred = select.value; + } populateSelect(select, apiConfigs, "No API configs configured"); }); } @@ -2195,15 +2242,21 @@ function updateShortcutOptionsForContainer(container, options = {}) { const profileSelect = card.querySelector(".shortcut-profile"); const taskSelect = card.querySelector(".shortcut-task"); if (envSelect) { - envSelect.dataset.preferred = envSelect.value; + if (envSelect.value) { + envSelect.dataset.preferred = envSelect.value; + } populateSelect(envSelect, envs, "No environments configured"); } if (profileSelect) { - profileSelect.dataset.preferred = profileSelect.value; + if (profileSelect.value) { + profileSelect.dataset.preferred = profileSelect.value; + } populateSelect(profileSelect, profiles, "No profiles configured"); } if (taskSelect) { - taskSelect.dataset.preferred = taskSelect.value; + if (taskSelect.value) { + taskSelect.dataset.preferred = taskSelect.value; + } populateSelect(taskSelect, tasks, "No tasks configured"); } }); @@ -2246,6 +2299,10 @@ function collectWorkspaces() { const nameInput = card.querySelector(".workspace-name"); const themeSelect = card.querySelector(".appearance-theme"); const toolbarSelect = card.querySelector(".appearance-toolbar-position"); + const defaultEnvProfileSelect = card.querySelector( + ".appearance-default-env-profile" + ); + const emptyToolbarSelect = card.querySelector(".appearance-empty-toolbar"); // Collect nested resources const envsContainer = card.querySelector(".workspace-envs"); @@ -2269,6 +2326,12 @@ function collectWorkspaces() { name: (nameInput?.value || "Untitled Workspace").trim(), theme: themeSelect?.value || "inherit", toolbarPosition: toolbarSelect?.value || "inherit", + alwaysUseDefaultEnvProfile: normalizeAppearanceToggle( + defaultEnvProfileSelect?.value + ), + emptyToolbarBehavior: normalizeEmptyToolbarBehavior( + emptyToolbarSelect?.value + ), envConfigs: envsContainer ? collectEnvConfigs(envsContainer) : [], profiles: profilesContainer ? collectProfiles(profilesContainer) : [], tasks: tasksContainer ? collectTasks(tasksContainer) : [], @@ -2381,12 +2444,196 @@ function renderWorkspaceSection(title, containerClass, items, builder, newItemFa summaryRight.appendChild(addBtn); body.appendChild(listContainer); details.appendChild(body); - + return details; } +const THEME_LABELS = { + system: "System", + light: "Light", + dark: "Dark" +}; + +const TOOLBAR_POSITION_LABELS = { + "bottom-right": "Bottom Right", + "bottom-left": "Bottom Left", + "top-right": "Top Right", + "top-left": "Top Left", + "bottom-center": "Bottom Center" +}; + +const EMPTY_TOOLBAR_BEHAVIOR_LABELS = { + hide: "Hide Toolbar", + open: 'Show "Open SiteCompanion"' +}; + +function normalizeAppearanceToggle(value) { + if (value === "inherit" || value === "enabled" || value === "disabled") { + return value; + } + if (value === true) return "enabled"; + if (value === false) return "disabled"; + return "inherit"; +} + +function normalizeEmptyToolbarBehavior(value, allowInherit = true) { + if (value === "hide" || value === "open") return value; + if (allowInherit && value === "inherit") return "inherit"; + return allowInherit ? "inherit" : "open"; +} + +function resolveAppearanceToggleValue(value, fallback) { + const normalized = normalizeAppearanceToggle(value); + if (normalized === "inherit") return Boolean(fallback); + return normalized === "enabled"; +} + +function getThemeLabel(value) { + return THEME_LABELS[value] || String(value || "System"); +} + +function getToolbarPositionLabel(value) { + return TOOLBAR_POSITION_LABELS[value] || String(value || "Bottom Right"); +} + +function getDefaultEnvProfileLabel(value) { + return value ? "Enabled" : "Disabled"; +} + +function getEmptyToolbarBehaviorLabel(value) { + return EMPTY_TOOLBAR_BEHAVIOR_LABELS[value] || "Hide Toolbar"; +} + +function getGlobalAppearanceConfig() { + return { + theme: themeSelect?.value || "system", + toolbarPosition: toolbarPositionSelect?.value || "bottom-right", + alwaysUseDefaultEnvProfile: resolveAppearanceToggleValue( + alwaysUseDefaultEnvProfileSelect?.value, + false + ), + emptyToolbarBehavior: normalizeEmptyToolbarBehavior( + emptyToolbarBehaviorSelect?.value, + false + ) + }; +} + +function updateAppearanceInheritedHint(selectEl, hintEl, label) { + if (!selectEl || !hintEl) return; + if (selectEl.value !== "inherit") { + hintEl.textContent = "Not inheriting"; + hintEl.classList.remove("hidden"); + return; + } + hintEl.textContent = `Inherited: ${label}`; + hintEl.classList.remove("hidden"); +} + +function updateAppearanceInheritanceIndicators() { + const global = getGlobalAppearanceConfig(); + const workspaceCards = document.querySelectorAll(".workspace-card"); + const workspaceAppearance = new Map(); + + workspaceCards.forEach((card) => { + const themeSelect = card.querySelector(".appearance-theme"); + const toolbarSelect = card.querySelector(".appearance-toolbar-position"); + const defaultSelect = card.querySelector(".appearance-default-env-profile"); + const emptyToolbarSelect = card.querySelector(".appearance-empty-toolbar"); + const themeValue = themeSelect?.value || "inherit"; + const toolbarValue = toolbarSelect?.value || "inherit"; + const defaultValue = defaultSelect?.value || "inherit"; + const emptyToolbarValue = normalizeEmptyToolbarBehavior( + emptyToolbarSelect?.value || "inherit" + ); + const resolvedTheme = + themeValue === "inherit" ? global.theme : themeValue; + const resolvedToolbar = + toolbarValue === "inherit" ? global.toolbarPosition : toolbarValue; + const resolvedDefault = resolveAppearanceToggleValue( + defaultValue, + global.alwaysUseDefaultEnvProfile + ); + const resolvedEmptyToolbar = + emptyToolbarValue === "inherit" + ? global.emptyToolbarBehavior + : emptyToolbarValue; + workspaceAppearance.set(card.dataset.id, { + theme: resolvedTheme, + toolbarPosition: resolvedToolbar, + alwaysUseDefaultEnvProfile: resolvedDefault, + emptyToolbarBehavior: resolvedEmptyToolbar + }); + + updateAppearanceInheritedHint( + themeSelect, + card.querySelector('.appearance-inherited[data-appearance-key="theme"]'), + getThemeLabel(global.theme) + ); + updateAppearanceInheritedHint( + toolbarSelect, + card.querySelector( + '.appearance-inherited[data-appearance-key="toolbarPosition"]' + ), + getToolbarPositionLabel(global.toolbarPosition) + ); + updateAppearanceInheritedHint( + defaultSelect, + card.querySelector( + '.appearance-inherited[data-appearance-key="alwaysUseDefaultEnvProfile"]' + ), + getDefaultEnvProfileLabel(global.alwaysUseDefaultEnvProfile) + ); + updateAppearanceInheritedHint( + emptyToolbarSelect, + card.querySelector( + '.appearance-inherited[data-appearance-key="emptyToolbarBehavior"]' + ), + getEmptyToolbarBehaviorLabel(global.emptyToolbarBehavior) + ); + }); + + const siteCards = document.querySelectorAll(".site-card"); + siteCards.forEach((card) => { + const workspaceId = card.querySelector(".site-workspace")?.value || "global"; + const resolved = + workspaceAppearance.get(workspaceId) || global; + updateAppearanceInheritedHint( + card.querySelector(".appearance-theme"), + card.querySelector('.appearance-inherited[data-appearance-key="theme"]'), + getThemeLabel(resolved.theme) + ); + updateAppearanceInheritedHint( + card.querySelector(".appearance-toolbar-position"), + card.querySelector( + '.appearance-inherited[data-appearance-key="toolbarPosition"]' + ), + getToolbarPositionLabel(resolved.toolbarPosition) + ); + updateAppearanceInheritedHint( + card.querySelector(".appearance-default-env-profile"), + card.querySelector( + '.appearance-inherited[data-appearance-key="alwaysUseDefaultEnvProfile"]' + ), + getDefaultEnvProfileLabel(resolved.alwaysUseDefaultEnvProfile) + ); + updateAppearanceInheritedHint( + card.querySelector(".appearance-empty-toolbar"), + card.querySelector( + '.appearance-inherited[data-appearance-key="emptyToolbarBehavior"]' + ), + getEmptyToolbarBehaviorLabel(resolved.emptyToolbarBehavior) + ); + }); +} + function buildAppearanceSection( - { theme = "inherit", toolbarPosition = "inherit" } = {}, + { + theme = "inherit", + toolbarPosition = "inherit", + alwaysUseDefaultEnvProfile = "inherit", + emptyToolbarBehavior = "inherit" + } = {}, { stateKey } = {} ) { const details = document.createElement("details"); @@ -2434,6 +2681,10 @@ function buildAppearanceSection( themeSelect.value = theme || "inherit"; themeField.appendChild(themeLabel); themeField.appendChild(themeSelect); + const themeHint = document.createElement("div"); + themeHint.className = "hint appearance-inherited hidden"; + themeHint.dataset.appearanceKey = "theme"; + themeField.appendChild(themeHint); const toolbarField = document.createElement("div"); toolbarField.className = "field"; @@ -2466,11 +2717,69 @@ function buildAppearanceSection( toolbarSelect.value = toolbarPosition || "inherit"; toolbarField.appendChild(toolbarLabel); toolbarField.appendChild(toolbarSelect); + const toolbarHint = document.createElement("div"); + toolbarHint.className = "hint appearance-inherited hidden"; + toolbarHint.dataset.appearanceKey = "toolbarPosition"; + toolbarField.appendChild(toolbarHint); + + const defaultField = document.createElement("div"); + defaultField.className = "field"; + const defaultLabel = document.createElement("label"); + defaultLabel.textContent = "Always use default ENV/PROFILE"; + const defaultSelect = document.createElement("select"); + defaultSelect.className = "appearance-default-env-profile"; + const defaultOptions = [ + { value: "inherit", label: "Inherit" }, + { value: "enabled", label: "Enabled" }, + { value: "disabled", label: "Disabled" } + ]; + for (const optValue of defaultOptions) { + const opt = document.createElement("option"); + opt.value = optValue.value; + opt.textContent = optValue.label; + defaultSelect.appendChild(opt); + } + defaultSelect.value = normalizeAppearanceToggle(alwaysUseDefaultEnvProfile); + defaultField.appendChild(defaultLabel); + defaultField.appendChild(defaultSelect); + const defaultHint = document.createElement("div"); + defaultHint.className = "hint appearance-inherited hidden"; + defaultHint.dataset.appearanceKey = "alwaysUseDefaultEnvProfile"; + defaultField.appendChild(defaultHint); + + const emptyToolbarField = document.createElement("div"); + emptyToolbarField.className = "field"; + const emptyToolbarLabel = document.createElement("label"); + emptyToolbarLabel.textContent = "Empty toolbar"; + const emptyToolbarSelect = document.createElement("select"); + emptyToolbarSelect.className = "appearance-empty-toolbar"; + const emptyToolbarOptions = [ + { value: "inherit", label: "Inherit" }, + { value: "hide", label: "Hide Toolbar" }, + { value: "open", label: 'Show "Open SiteCompanion"' } + ]; + for (const optionConfig of emptyToolbarOptions) { + const opt = document.createElement("option"); + opt.value = optionConfig.value; + opt.textContent = optionConfig.label; + emptyToolbarSelect.appendChild(opt); + } + emptyToolbarSelect.value = normalizeEmptyToolbarBehavior( + emptyToolbarBehavior + ); + emptyToolbarField.appendChild(emptyToolbarLabel); + emptyToolbarField.appendChild(emptyToolbarSelect); + const emptyToolbarHint = document.createElement("div"); + emptyToolbarHint.className = "hint appearance-inherited hidden"; + emptyToolbarHint.dataset.appearanceKey = "emptyToolbarBehavior"; + emptyToolbarField.appendChild(emptyToolbarHint); const appearanceRow = document.createElement("div"); - appearanceRow.className = "inline-fields two appearance-fields"; + appearanceRow.className = "inline-fields four appearance-fields"; appearanceRow.appendChild(themeField); appearanceRow.appendChild(toolbarField); + appearanceRow.appendChild(defaultField); + appearanceRow.appendChild(emptyToolbarField); body.appendChild(appearanceRow); details.appendChild(body); registerDetail(details, details.open); @@ -3983,6 +4292,7 @@ function buildWorkspaceCard(ws, allWorkspaces = [], allSites = []) { if (confirm(`Delete workspace "${ws.name}"? All items will move to global.`)) { card.remove(); scheduleSidebarErrors(); + updateAppearanceInheritanceIndicators(); updateToc(collectWorkspaces(), collectSites()); } }); @@ -3993,7 +4303,13 @@ function buildWorkspaceCard(ws, allWorkspaces = [], allSites = []) { const appearanceSection = buildAppearanceSection( { theme: ws.theme || "inherit", - toolbarPosition: ws.toolbarPosition || "inherit" + toolbarPosition: ws.toolbarPosition || "inherit", + alwaysUseDefaultEnvProfile: normalizeAppearanceToggle( + ws.alwaysUseDefaultEnvProfile + ), + emptyToolbarBehavior: normalizeEmptyToolbarBehavior( + ws.emptyToolbarBehavior + ) }, { stateKey: `workspace:${card.dataset.id}:appearance` } ); @@ -4214,6 +4530,10 @@ function collectSites() { const parsedTarget = parseExtractionTargetInput(extractInput?.value || ""); const themeSelect = card.querySelector(".appearance-theme"); const toolbarSelect = card.querySelector(".appearance-toolbar-position"); + const defaultEnvProfileSelect = card.querySelector( + ".appearance-default-env-profile" + ); + const emptyToolbarSelect = card.querySelector(".appearance-empty-toolbar"); const envsContainer = card.querySelector(".site-envs"); const profilesContainer = card.querySelector(".site-profiles"); const tasksContainer = card.querySelector(".site-tasks"); @@ -4231,6 +4551,12 @@ function collectSites() { extractTarget: parsedTarget.target, theme: themeSelect?.value || "inherit", toolbarPosition: toolbarSelect?.value || "inherit", + alwaysUseDefaultEnvProfile: normalizeAppearanceToggle( + defaultEnvProfileSelect?.value + ), + emptyToolbarBehavior: normalizeEmptyToolbarBehavior( + emptyToolbarSelect?.value + ), envConfigs: envsContainer ? collectEnvConfigs(envsContainer) : [], profiles: profilesContainer ? collectProfiles(profilesContainer) : [], tasks: tasksContainer ? collectTasks(tasksContainer) : [], @@ -4380,7 +4706,13 @@ function buildSiteCard(site, allWorkspaces = []) { const appearanceSection = buildAppearanceSection( { theme: site.theme || "inherit", - toolbarPosition: site.toolbarPosition || "inherit" + toolbarPosition: site.toolbarPosition || "inherit", + alwaysUseDefaultEnvProfile: normalizeAppearanceToggle( + site.alwaysUseDefaultEnvProfile + ), + emptyToolbarBehavior: normalizeEmptyToolbarBehavior( + site.emptyToolbarBehavior + ) }, { stateKey: `site:${card.dataset.id}:appearance` } ); @@ -5026,6 +5358,7 @@ function updateShortcutControls(container = shortcutsContainer) { if (moveDownBtn) moveDownBtn.disabled = index === cards.length - 1; }); scheduleSidebarErrors(); + scheduleTocUpdate(); } function updateTaskControls(container = tasksContainer) { @@ -5039,6 +5372,7 @@ function updateTaskControls(container = tasksContainer) { if (moveDownBtn) moveDownBtn.disabled = index === cards.length - 1; }); scheduleSidebarErrors(); + scheduleTocUpdate(); } function collectTasks(container = tasksContainer) { @@ -5345,6 +5679,8 @@ async function loadSettings() { toolbarPosition = "bottom-right", toolbarAutoHide: storedToolbarAutoHide = true, alwaysShowOutput: storedAlwaysShowOutput = false, + alwaysUseDefaultEnvProfile: storedAlwaysUseDefaultEnvProfile = false, + emptyToolbarBehavior: storedEmptyToolbarBehavior = "open", sidebarWidth } = await getStorage([ "apiKey", @@ -5367,6 +5703,8 @@ async function loadSettings() { "sites", "toolbarPosition", "toolbarAutoHide", + "emptyToolbarBehavior", + "alwaysUseDefaultEnvProfile", SIDEBAR_WIDTH_KEY ]); @@ -5379,9 +5717,22 @@ async function loadSettings() { if (toolbarAutoHide) { toolbarAutoHide.checked = Boolean(storedToolbarAutoHide); } + if (emptyToolbarBehaviorSelect) { + emptyToolbarBehaviorSelect.value = normalizeEmptyToolbarBehavior( + storedEmptyToolbarBehavior, + false + ); + } if (alwaysShowOutput) { alwaysShowOutput.checked = Boolean(storedAlwaysShowOutput); } + if (alwaysUseDefaultEnvProfileSelect) { + const normalizedDefault = normalizeAppearanceToggle( + storedAlwaysUseDefaultEnvProfile + ); + alwaysUseDefaultEnvProfileSelect.value = + normalizedDefault === "enabled" ? "enabled" : "disabled"; + } if (Number.isFinite(sidebarWidth)) { applySidebarWidth(sidebarWidth); } @@ -5417,6 +5768,12 @@ async function loadSettings() { ...workspace, theme: workspace.theme || "inherit", toolbarPosition: workspace.toolbarPosition || "inherit", + alwaysUseDefaultEnvProfile: normalizeAppearanceToggle( + workspace.alwaysUseDefaultEnvProfile + ), + emptyToolbarBehavior: normalizeEmptyToolbarBehavior( + workspace.emptyToolbarBehavior + ), envConfigs: normalizeConfigList(workspace.envConfigs), profiles: normalizeConfigList(workspace.profiles), tasks: normalizeConfigList(workspace.tasks), @@ -5441,6 +5798,12 @@ async function loadSettings() { extractTarget: normalizedTarget.target, theme: site.theme || "inherit", toolbarPosition: site.toolbarPosition || "inherit", + alwaysUseDefaultEnvProfile: normalizeAppearanceToggle( + site.alwaysUseDefaultEnvProfile + ), + emptyToolbarBehavior: normalizeEmptyToolbarBehavior( + site.emptyToolbarBehavior + ), envConfigs: normalizeConfigList(site.envConfigs), profiles: normalizeConfigList(site.profiles), tasks: normalizeConfigList(site.tasks), @@ -5742,6 +6105,7 @@ async function loadSettings() { updateSidebarErrors(); updateToc(workspaces, sites); renderGlobalSitesList(sites); + updateAppearanceInheritanceIndicators(); } async function saveSettings() { @@ -5870,8 +6234,14 @@ async function saveSettings() { toolbarPosition: toolbarPositionSelect ? toolbarPositionSelect.value : "bottom-right", + emptyToolbarBehavior: emptyToolbarBehaviorSelect + ? emptyToolbarBehaviorSelect.value + : "open", toolbarAutoHide: toolbarAutoHide ? toolbarAutoHide.checked : true, alwaysShowOutput: alwaysShowOutput ? alwaysShowOutput.checked : false, + alwaysUseDefaultEnvProfile: alwaysUseDefaultEnvProfileSelect + ? alwaysUseDefaultEnvProfileSelect.value === "enabled" + : false, workspaces: updatedWorkspaces, sites: mergedSites }); @@ -6016,6 +6386,8 @@ addWorkspaceBtn.addEventListener("click", () => { name: "New Workspace", theme: "inherit", toolbarPosition: "inherit", + alwaysUseDefaultEnvProfile: "inherit", + emptyToolbarBehavior: "inherit", envConfigs: [], profiles: [], tasks: [], @@ -6032,6 +6404,7 @@ addWorkspaceBtn.addEventListener("click", () => { centerCardInView(newCard); refreshWorkspaceInheritedLists(); scheduleSidebarErrors(); + updateAppearanceInheritanceIndicators(); updateToc(collectWorkspaces(), collectSites()); }); @@ -6044,6 +6417,8 @@ addSiteBtn.addEventListener("click", () => { workspaceId: "global", theme: "inherit", toolbarPosition: "inherit", + alwaysUseDefaultEnvProfile: "inherit", + emptyToolbarBehavior: "inherit", envConfigs: [], profiles: [], tasks: [], @@ -6060,6 +6435,7 @@ addSiteBtn.addEventListener("click", () => { centerCardInView(newCard); refreshSiteInheritedLists(); scheduleSidebarErrors(); + updateAppearanceInheritanceIndicators(); updateToc(collectWorkspaces(), collectSites()); }); @@ -6109,6 +6485,9 @@ async function initSettings() { registerAllDetails(); restoreScrollPosition(); initDirtyObserver(); + if (settingsLayout) { + settingsLayout.addEventListener("input", handleTocNameInput); + } captureSavedSnapshot(); suppressDirtyTracking = false; window.addEventListener("scroll", handleSettingsScroll, { passive: true }); @@ -6563,6 +6942,7 @@ function handleSettingsInputChange() { scheduleSidebarErrors(); scheduleDirtyCheck(); refreshInheritedSourceLabels(); + updateAppearanceInheritanceIndicators(); } document.addEventListener("input", handleSettingsInputChange);