diff --git a/wwcompanion-extension/background.js b/wwcompanion-extension/background.js index 873b18c..36bacc9 100644 --- a/wwcompanion-extension/background.js +++ b/wwcompanion-extension/background.js @@ -21,6 +21,7 @@ const DEFAULT_SETTINGS = { activeApiConfigId: "", envConfigs: [], activeEnvConfigId: "", + profiles: [], apiBaseUrl: "https://api.openai.com/v1", apiKeyHeader: "Authorization", apiKeyPrefix: "Bearer ", @@ -242,12 +243,44 @@ chrome.runtime.onInstalled.addListener(async () => { } } + const hasProfiles = + Array.isArray(stored.profiles) && stored.profiles.length > 0; + if (!hasProfiles) { + const id = crypto?.randomUUID + ? crypto.randomUUID() + : `profile-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; + updates.profiles = [ + { + id, + name: "Default", + text: stored.resume || "", + type: "Resume" + } + ]; + } else { + const normalizedProfiles = stored.profiles.map((profile) => ({ + ...profile, + text: profile.text ?? "", + type: profile.type === "Profile" ? "Profile" : "Resume" + })); + const needsProfileUpdate = normalizedProfiles.some( + (profile, index) => + (profile.text || "") !== (stored.profiles[index]?.text || "") || + (profile.type || "Resume") !== (stored.profiles[index]?.type || "Resume") + ); + if (needsProfileUpdate) { + updates.profiles = normalizedProfiles; + } + } + const resolvedEnvConfigs = updates.envConfigs || stored.envConfigs || []; const defaultEnvId = resolvedEnvConfigs[0]?.id || updates.activeEnvConfigId || stored.activeEnvConfigId || ""; + const resolvedProfiles = updates.profiles || stored.profiles || []; + const defaultProfileId = resolvedProfiles[0]?.id || ""; const taskSource = Array.isArray(updates.tasks) ? updates.tasks : Array.isArray(stored.tasks) @@ -256,10 +289,13 @@ chrome.runtime.onInstalled.addListener(async () => { if (taskSource.length) { const normalizedTasks = taskSource.map((task) => ({ ...task, - defaultEnvId: task.defaultEnvId || defaultEnvId + defaultEnvId: task.defaultEnvId || defaultEnvId, + defaultProfileId: task.defaultProfileId || defaultProfileId })); const needsTaskUpdate = normalizedTasks.some( - (task, index) => task.defaultEnvId !== taskSource[index]?.defaultEnvId + (task, index) => + task.defaultEnvId !== taskSource[index]?.defaultEnvId || + task.defaultProfileId !== taskSource[index]?.defaultProfileId ); if (needsTaskUpdate) { updates.tasks = normalizedTasks; @@ -328,9 +364,10 @@ chrome.runtime.onMessage.addListener((message) => { } }); -function buildUserMessage(resume, task, posting) { +function buildUserMessage(resume, resumeType, task, posting) { + const header = resumeType === "Profile" ? "=== PROFILE ===" : "=== RESUME ==="; return [ - "=== RESUME ===", + header, resume || "", "", "=== TASK ===", @@ -370,6 +407,7 @@ async function handleAnalysisRequest(port, payload, signal) { model, systemPrompt, resume, + resumeType, taskText, postingText, tabId @@ -416,7 +454,12 @@ async function handleAnalysisRequest(port, payload, signal) { return; } - const userMessage = buildUserMessage(resume, taskText, postingText); + const userMessage = buildUserMessage( + resume, + resumeType, + taskText, + postingText + ); await chrome.storage.local.set({ [OUTPUT_STORAGE_KEY]: "" }); openKeepalive(tabId); diff --git a/wwcompanion-extension/manifest.json b/wwcompanion-extension/manifest.json index 3df4559..e7d4804 100644 --- a/wwcompanion-extension/manifest.json +++ b/wwcompanion-extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "WWCompanion", - "version": "0.3.0", + "version": "0.3.1", "description": "AI companion for WaterlooWorks job postings.", "permissions": ["storage", "activeTab"], "host_permissions": ["https://waterlooworks.uwaterloo.ca/*"], diff --git a/wwcompanion-extension/popup.css b/wwcompanion-extension/popup.css index 20bfa5b..b824e55 100644 --- a/wwcompanion-extension/popup.css +++ b/wwcompanion-extension/popup.css @@ -127,12 +127,13 @@ select { font-size: 12px; } -.env-row { +.selector-row { display: flex; align-items: flex-end; + gap: 8px; } -.env-row .env-field { +.selector-row .selector-field { flex: 1; margin: 0; } diff --git a/wwcompanion-extension/popup.html b/wwcompanion-extension/popup.html index 67d3ca9..b91f578 100644 --- a/wwcompanion-extension/popup.html +++ b/wwcompanion-extension/popup.html @@ -17,11 +17,15 @@
-
-
+
+
+
+ + +
diff --git a/wwcompanion-extension/popup.js b/wwcompanion-extension/popup.js index b5086c9..a4ab4f1 100644 --- a/wwcompanion-extension/popup.js +++ b/wwcompanion-extension/popup.js @@ -2,6 +2,7 @@ const runBtn = document.getElementById("runBtn"); const abortBtn = document.getElementById("abortBtn"); const taskSelect = document.getElementById("taskSelect"); const envSelect = document.getElementById("envSelect"); +const profileSelect = document.getElementById("profileSelect"); const outputEl = document.getElementById("output"); const statusEl = document.getElementById("status"); const postingCountEl = document.getElementById("postingCount"); @@ -15,26 +16,30 @@ const OUTPUT_STORAGE_KEY = "lastOutput"; const AUTO_RUN_KEY = "autoRunDefaultTask"; const LAST_TASK_KEY = "lastSelectedTaskId"; const LAST_ENV_KEY = "lastSelectedEnvId"; +const LAST_PROFILE_KEY = "lastSelectedProfileId"; const state = { postingText: "", tasks: [], envs: [], + profiles: [], port: null, isAnalyzing: false, outputRaw: "", autoRunPending: false, selectedTaskId: "", - selectedEnvId: "" + selectedEnvId: "", + selectedProfileId: "" }; function getStorage(keys) { return new Promise((resolve) => chrome.storage.local.get(keys, resolve)); } -function buildUserMessage(resume, task, posting) { +function buildUserMessage(resume, resumeType, task, posting) { + const header = resumeType === "Profile" ? "=== PROFILE ===" : "=== RESUME ==="; return [ - "=== RESUME ===", + header, resume || "", "", "=== TASK ===", @@ -253,6 +258,7 @@ function setAnalyzing(isAnalyzing) { abortBtn.classList.toggle("hidden", !isAnalyzing); updateTaskSelectState(); updateEnvSelectState(); + updateProfileSelectState(); } function updatePostingCount() { @@ -317,10 +323,41 @@ function updateEnvSelectState() { envSelect.disabled = state.isAnalyzing || !hasEnvs; } +function renderProfiles(profiles) { + state.profiles = profiles; + profileSelect.innerHTML = ""; + + if (!profiles.length) { + const option = document.createElement("option"); + option.textContent = "No profiles configured"; + option.value = ""; + profileSelect.appendChild(option); + updateProfileSelectState(); + return; + } + + for (const profile of profiles) { + const option = document.createElement("option"); + option.value = profile.id; + option.textContent = profile.name || "Default"; + profileSelect.appendChild(option); + } + updateProfileSelectState(); +} + +function updateProfileSelectState() { + const hasProfiles = state.profiles.length > 0; + profileSelect.disabled = state.isAnalyzing || !hasProfiles; +} + function getTaskDefaultEnvId(task) { return task?.defaultEnvId || state.envs[0]?.id || ""; } +function getTaskDefaultProfileId(task) { + return task?.defaultProfileId || state.profiles[0]?.id || ""; +} + function setEnvironmentSelection(envId) { const target = envId && state.envs.some((env) => env.id === envId) @@ -332,6 +369,17 @@ function setEnvironmentSelection(envId) { state.selectedEnvId = target; } +function setProfileSelection(profileId) { + const target = + profileId && state.profiles.some((profile) => profile.id === profileId) + ? profileId + : state.profiles[0]?.id || ""; + if (target) { + profileSelect.value = target; + } + state.selectedProfileId = target; +} + function selectTask(taskId, { resetEnv } = { resetEnv: false }) { if (!taskId) return; taskSelect.value = taskId; @@ -339,13 +387,15 @@ function selectTask(taskId, { resetEnv } = { resetEnv: false }) { const task = state.tasks.find((item) => item.id === taskId); if (resetEnv) { setEnvironmentSelection(getTaskDefaultEnvId(task)); + setProfileSelection(getTaskDefaultProfileId(task)); } } async function persistSelections() { await chrome.storage.local.set({ [LAST_TASK_KEY]: state.selectedTaskId, - [LAST_ENV_KEY]: state.selectedEnvId + [LAST_ENV_KEY]: state.selectedEnvId, + [LAST_PROFILE_KEY]: state.selectedProfileId }); } @@ -439,22 +489,28 @@ async function loadConfig() { const stored = await getStorage([ "tasks", "envConfigs", + "profiles", LAST_TASK_KEY, - LAST_ENV_KEY + LAST_ENV_KEY, + LAST_PROFILE_KEY ]); const tasks = Array.isArray(stored.tasks) ? stored.tasks : []; const envs = Array.isArray(stored.envConfigs) ? stored.envConfigs : []; + const profiles = Array.isArray(stored.profiles) ? stored.profiles : []; renderTasks(tasks); renderEnvironments(envs); + renderProfiles(profiles); if (!tasks.length) { state.selectedTaskId = ""; - state.selectedEnvId = envs[0]?.id || ""; + setEnvironmentSelection(envs[0]?.id || ""); + setProfileSelection(profiles[0]?.id || ""); return; } const storedTaskId = stored[LAST_TASK_KEY]; const storedEnvId = stored[LAST_ENV_KEY]; + const storedProfileId = stored[LAST_PROFILE_KEY]; const initialTaskId = tasks.some((task) => task.id === storedTaskId) ? storedTaskId : tasks[0].id; @@ -467,9 +523,16 @@ async function loadConfig() { setEnvironmentSelection(getTaskDefaultEnvId(task)); } + if (storedProfileId && profiles.some((profile) => profile.id === storedProfileId)) { + setProfileSelection(storedProfileId); + } else { + setProfileSelection(getTaskDefaultProfileId(task)); + } + if ( storedTaskId !== state.selectedTaskId || - storedEnvId !== state.selectedEnvId + storedEnvId !== state.selectedEnvId || + storedProfileId !== state.selectedProfileId ) { await persistSelections(); } @@ -528,6 +591,7 @@ async function handleAnalyze() { apiConfigs = [], activeApiConfigId = "", envConfigs = [], + profiles = [], apiBaseUrl, apiKeyHeader, apiKeyPrefix, @@ -540,6 +604,7 @@ async function handleAnalyze() { "apiConfigs", "activeApiConfigId", "envConfigs", + "profiles", "apiBaseUrl", "apiKeyHeader", "apiKeyPrefix", @@ -550,6 +615,7 @@ async function handleAnalyze() { const resolvedConfigs = Array.isArray(apiConfigs) ? apiConfigs : []; const resolvedEnvs = Array.isArray(envConfigs) ? envConfigs : []; + const resolvedProfiles = Array.isArray(profiles) ? profiles : []; const selectedEnvId = envSelect.value; const activeEnv = resolvedEnvs.find((entry) => entry.id === selectedEnvId) || @@ -569,6 +635,13 @@ async function handleAnalyze() { setStatus("Add an API configuration in Settings."); return; } + + const selectedProfileId = profileSelect.value; + const activeProfile = + resolvedProfiles.find((entry) => entry.id === selectedProfileId) || + resolvedProfiles[0]; + const resumeText = activeProfile?.text || resume || ""; + const resumeType = activeProfile?.type || "Resume"; const isAdvanced = Boolean(activeConfig?.advanced); const resolvedApiUrl = activeConfig?.apiUrl || ""; const resolvedTemplate = activeConfig?.requestTemplate || ""; @@ -614,7 +687,12 @@ async function handleAnalyze() { } } - const promptText = buildUserMessage(resume || "", task.text || "", state.postingText); + const promptText = buildUserMessage( + resumeText, + resumeType, + task.text || "", + state.postingText + ); updatePromptCount(promptText.length); state.outputRaw = ""; @@ -635,7 +713,8 @@ async function handleAnalyze() { apiKeyPrefix: resolvedApiKeyPrefix, model: resolvedModel, systemPrompt: resolvedSystemPrompt, - resume: resume || "", + resume: resumeText, + resumeType, taskText: task.text || "", postingText: state.postingText, tabId: tab.id @@ -704,6 +783,10 @@ envSelect.addEventListener("change", () => { setEnvironmentSelection(envSelect.value); void persistSelections(); }); +profileSelect.addEventListener("change", () => { + setProfileSelection(profileSelect.value); + void persistSelections(); +}); updatePostingCount(); updatePromptCount(0); diff --git a/wwcompanion-extension/settings.css b/wwcompanion-extension/settings.css index 21f63bf..fc89b3f 100644 --- a/wwcompanion-extension/settings.css +++ b/wwcompanion-extension/settings.css @@ -42,6 +42,77 @@ body { background: var(--bg); } +.settings-layout { + display: flex; + gap: 16px; +} + +.toc { + flex: 0 0 160px; + display: flex; + flex-direction: column; + gap: 8px; + align-self: flex-start; + position: sticky; + top: 16px; + padding: 12px; + border-radius: 12px; + border: 1px solid var(--border); + background: var(--panel); + box-shadow: var(--panel-shadow); +} + +.toc-title { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.8px; + color: var(--muted); +} + +.toc-heading { + font-size: 16px; + font-weight: 700; + color: var(--ink); +} + +.toc-links { + display: grid; + gap: 8px; +} + +.toc a { + color: var(--ink); + text-decoration: none; + font-size: 12px; + padding: 4px 6px; + border-radius: 8px; +} + +.toc a:hover { + background: var(--card-bg); +} + +.sidebar-errors { + margin-top: auto; + border-radius: 10px; + border: 1px solid #c0392b; + background: rgba(192, 57, 43, 0.08); + color: #c0392b; + font-size: 11px; + padding: 8px; + white-space: pre-line; +} + +.hidden { + display: none; +} + +.settings-main { + flex: 1; + display: grid; + gap: 14px; +} + .title-block { margin-bottom: 16px; } @@ -69,7 +140,6 @@ body { border: 1px solid var(--border); border-radius: 16px; padding: 16px; - margin-bottom: 16px; box-shadow: var(--panel-shadow); } @@ -288,7 +358,15 @@ button:active { .api-config-actions { display: flex; gap: 8px; - justify-content: flex-end; + justify-content: space-between; + align-items: center; +} + +.api-config-actions-left, +.api-config-actions-right { + display: flex; + gap: 8px; + flex-wrap: wrap; } .env-configs { @@ -311,6 +389,32 @@ button:active { justify-content: flex-end; } +.profiles { + display: grid; + gap: 12px; +} + +.profile-card { + padding: 12px; + border-radius: 12px; + border: 1px solid var(--border); + background: var(--card-bg); + display: grid; + gap: 8px; +} + +.profile-actions { + display: flex; + gap: 8px; + justify-content: flex-end; +} + +.profile-actions .delete { + background: #c0392b; + border-color: #c0392b; + color: #fff6f2; +} + @media (prefers-color-scheme: dark) { :root:not([data-theme]), :root[data-theme="system"] { @@ -333,3 +437,14 @@ button:active { gap: 6px; justify-content: flex-end; } + +@media (max-width: 720px) { + .settings-layout { + flex-direction: column; + } + + .toc { + width: 100%; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + } +} diff --git a/wwcompanion-extension/settings.html b/wwcompanion-extension/settings.html index b0ef5e5..5c9188c 100644 --- a/wwcompanion-extension/settings.html +++ b/wwcompanion-extension/settings.html @@ -16,7 +16,24 @@
-
+
+ +
+
-
+
-
+
-
+
-
+
-

Resume

- Text to your profile goes here +

My Profiles

+ Text to your resumes or generic profiles goes here
- +
+
+ +
+
-
+
+ +
diff --git a/wwcompanion-extension/settings.js b/wwcompanion-extension/settings.js index 61ea3fb..f3f1d30 100644 --- a/wwcompanion-extension/settings.js +++ b/wwcompanion-extension/settings.js @@ -1,5 +1,5 @@ -const resumeInput = document.getElementById("resume"); const saveBtn = document.getElementById("saveBtn"); +const saveBtnSidebar = document.getElementById("saveBtnSidebar"); const addApiConfigBtn = document.getElementById("addApiConfigBtn"); const apiConfigsContainer = document.getElementById("apiConfigs"); const addApiKeyBtn = document.getElementById("addApiKeyBtn"); @@ -8,7 +8,11 @@ const addEnvConfigBtn = document.getElementById("addEnvConfigBtn"); const envConfigsContainer = document.getElementById("envConfigs"); const addTaskBtn = document.getElementById("addTaskBtn"); const tasksContainer = document.getElementById("tasks"); +const addProfileBtn = document.getElementById("addProfileBtn"); +const profilesContainer = document.getElementById("profiles"); const statusEl = document.getElementById("status"); +const statusSidebarEl = document.getElementById("statusSidebar"); +const sidebarErrorsEl = document.getElementById("sidebarErrors"); const themeSelect = document.getElementById("themeSelect"); const OPENAI_DEFAULTS = { @@ -26,12 +30,24 @@ function getStorage(keys) { function setStatus(message) { statusEl.textContent = message; + if (statusSidebarEl) statusSidebarEl.textContent = message; if (!message) return; setTimeout(() => { if (statusEl.textContent === message) statusEl.textContent = ""; + if (statusSidebarEl?.textContent === message) statusSidebarEl.textContent = ""; }, 2000); } +let sidebarErrorFrame = null; +function scheduleSidebarErrors() { + if (!sidebarErrorsEl) return; + if (sidebarErrorFrame) return; + sidebarErrorFrame = requestAnimationFrame(() => { + sidebarErrorFrame = null; + updateSidebarErrors(); + }); +} + function applyTheme(theme) { const value = theme || "system"; document.documentElement.dataset.theme = value; @@ -57,6 +73,11 @@ function newEnvConfigId() { return `env-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; } +function newProfileId() { + if (crypto?.randomUUID) return crypto.randomUUID(); + return `profile-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; +} + function buildChatUrlFromBase(baseUrl) { const trimmed = (baseUrl || "").trim().replace(/\/+$/, ""); if (!trimmed) return ""; @@ -94,6 +115,10 @@ function getTopEnvId() { return collectEnvConfigs()[0]?.id || ""; } +function getTopProfileId() { + return collectProfiles()[0]?.id || ""; +} + function setApiConfigAdvanced(card, isAdvanced) { card.classList.toggle("is-advanced", isAdvanced); card.dataset.mode = isAdvanced ? "advanced" : "basic"; @@ -253,6 +278,10 @@ function buildApiConfigCard(config) { const actions = document.createElement("div"); actions.className = "api-config-actions"; + const leftActions = document.createElement("div"); + leftActions.className = "api-config-actions-left"; + const rightActions = document.createElement("div"); + rightActions.className = "api-config-actions-right"; const moveTopBtn = document.createElement("button"); moveTopBtn.type = "button"; moveTopBtn.className = "ghost move-top"; @@ -265,6 +294,10 @@ function buildApiConfigCard(config) { moveDownBtn.type = "button"; moveDownBtn.className = "ghost move-down"; moveDownBtn.textContent = "Down"; + const addBelowBtn = document.createElement("button"); + addBelowBtn.type = "button"; + addBelowBtn.className = "ghost add-below"; + addBelowBtn.textContent = "Add"; moveTopBtn.addEventListener("click", () => { const first = apiConfigsContainer.firstElementChild; @@ -290,9 +323,27 @@ function buildApiConfigCard(config) { updateEnvApiOptions(); }); - actions.appendChild(moveTopBtn); - actions.appendChild(moveUpBtn); - actions.appendChild(moveDownBtn); + addBelowBtn.addEventListener("click", () => { + const name = buildUniqueDefaultName( + collectNames(apiConfigsContainer, ".api-config-name") + ); + const newCard = buildApiConfigCard({ + id: newApiConfigId(), + name, + apiBaseUrl: OPENAI_DEFAULTS.apiBaseUrl, + apiKeyHeader: OPENAI_DEFAULTS.apiKeyHeader, + apiKeyPrefix: OPENAI_DEFAULTS.apiKeyPrefix, + model: DEFAULT_MODEL, + apiUrl: "", + requestTemplate: "", + advanced: false + }); + card.insertAdjacentElement("afterend", newCard); + updateApiConfigKeyOptions(); + updateEnvApiOptions(); + updateApiConfigControls(); + }); + const advancedBtn = document.createElement("button"); advancedBtn.type = "button"; advancedBtn.className = "ghost advanced-toggle"; @@ -314,8 +365,6 @@ function buildApiConfigCard(config) { setApiConfigAdvanced(card, true); updateEnvApiOptions(); }); - actions.appendChild(advancedBtn); - const duplicateBtn = document.createElement("button"); duplicateBtn.type = "button"; duplicateBtn.className = "ghost duplicate"; @@ -330,8 +379,6 @@ function buildApiConfigCard(config) { updateApiConfigKeyOptions(); updateEnvApiOptions(); }); - actions.appendChild(duplicateBtn); - const resetBtn = document.createElement("button"); resetBtn.type = "button"; resetBtn.className = "ghost reset-openai"; @@ -346,8 +393,6 @@ function buildApiConfigCard(config) { setApiConfigAdvanced(card, false); updateEnvApiOptions(); }); - actions.appendChild(resetBtn); - const deleteBtn = document.createElement("button"); deleteBtn.type = "button"; deleteBtn.className = "ghost delete"; @@ -357,7 +402,6 @@ function buildApiConfigCard(config) { updateEnvApiOptions(); updateApiConfigControls(); }); - actions.appendChild(deleteBtn); const updateSelect = () => updateEnvApiOptions(); nameInput.addEventListener("input", updateSelect); @@ -368,6 +412,19 @@ function buildApiConfigCard(config) { urlInput.addEventListener("input", updateSelect); templateInput.addEventListener("input", updateSelect); + rightActions.appendChild(moveTopBtn); + rightActions.appendChild(moveUpBtn); + rightActions.appendChild(moveDownBtn); + rightActions.appendChild(addBelowBtn); + rightActions.appendChild(duplicateBtn); + rightActions.appendChild(deleteBtn); + + leftActions.appendChild(advancedBtn); + leftActions.appendChild(resetBtn); + + actions.appendChild(leftActions); + actions.appendChild(rightActions); + card.appendChild(nameField); card.appendChild(keyField); card.appendChild(baseField); @@ -398,6 +455,7 @@ function updateApiConfigControls() { if (moveUpBtn) moveUpBtn.disabled = index === 0; if (moveDownBtn) moveDownBtn.disabled = index === cards.length - 1; }); + scheduleSidebarErrors(); } function buildApiKeyCard(entry) { @@ -456,6 +514,10 @@ function buildApiKeyCard(entry) { moveDownBtn.type = "button"; moveDownBtn.className = "ghost move-down"; moveDownBtn.textContent = "Down"; + const addBelowBtn = document.createElement("button"); + addBelowBtn.type = "button"; + addBelowBtn.className = "ghost add-below"; + addBelowBtn.textContent = "Add"; moveTopBtn.addEventListener("click", () => { const first = apiKeysContainer.firstElementChild; @@ -481,9 +543,20 @@ function buildApiKeyCard(entry) { updateApiConfigKeyOptions(); }); + addBelowBtn.addEventListener("click", () => { + const name = buildUniqueDefaultName( + collectNames(apiKeysContainer, ".api-key-name") + ); + const newCard = buildApiKeyCard({ id: newApiKeyId(), name, key: "" }); + card.insertAdjacentElement("afterend", newCard); + updateApiConfigKeyOptions(); + updateApiKeyControls(); + }); + actions.appendChild(moveTopBtn); actions.appendChild(moveUpBtn); actions.appendChild(moveDownBtn); + actions.appendChild(addBelowBtn); const deleteBtn = document.createElement("button"); deleteBtn.type = "button"; deleteBtn.className = "ghost delete"; @@ -529,6 +602,7 @@ function updateApiKeyControls() { if (moveUpBtn) moveUpBtn.disabled = index === 0; if (moveDownBtn) moveDownBtn.disabled = index === cards.length - 1; }); + scheduleSidebarErrors(); } function updateApiConfigKeyOptions() { @@ -615,6 +689,10 @@ function buildEnvConfigCard(config) { moveDownBtn.type = "button"; moveDownBtn.className = "ghost move-down"; moveDownBtn.textContent = "Down"; + const addBelowBtn = document.createElement("button"); + addBelowBtn.type = "button"; + addBelowBtn.className = "ghost add-below"; + addBelowBtn.textContent = "Add"; moveTopBtn.addEventListener("click", () => { const first = envConfigsContainer.firstElementChild; @@ -643,6 +721,24 @@ function buildEnvConfigCard(config) { actions.appendChild(moveTopBtn); actions.appendChild(moveUpBtn); actions.appendChild(moveDownBtn); + actions.appendChild(addBelowBtn); + + addBelowBtn.addEventListener("click", () => { + const name = buildUniqueDefaultName( + collectNames(envConfigsContainer, ".env-config-name") + ); + const fallbackApiConfigId = collectApiConfigs()[0]?.id || ""; + const newCard = buildEnvConfigCard({ + id: newEnvConfigId(), + name, + apiConfigId: fallbackApiConfigId, + systemPrompt: DEFAULT_SYSTEM_PROMPT + }); + card.insertAdjacentElement("afterend", newCard); + updateEnvApiOptions(); + updateEnvControls(); + updateTaskEnvOptions(); + }); const duplicateBtn = document.createElement("button"); duplicateBtn.type = "button"; @@ -700,6 +796,7 @@ function updateEnvControls() { if (moveUpBtn) moveUpBtn.disabled = index === 0; if (moveDownBtn) moveDownBtn.disabled = index === cards.length - 1; }); + scheduleSidebarErrors(); } function updateTaskEnvOptions() { @@ -733,6 +830,7 @@ function updateTaskEnvOptions() { select.dataset.preferred = select.value; }); + scheduleSidebarErrors(); } function collectEnvConfigs() { @@ -750,6 +848,222 @@ function collectEnvConfigs() { }); } +function buildProfileCard(profile) { + const card = document.createElement("div"); + card.className = "profile-card"; + card.dataset.id = profile.id || newProfileId(); + + 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 = profile.name || ""; + nameInput.className = "profile-name"; + 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"); + textLabel.textContent = "Profile text"; + const textArea = document.createElement("textarea"); + textArea.rows = 8; + textArea.value = profile.text || ""; + textArea.className = "profile-text"; + textField.appendChild(textLabel); + textField.appendChild(textArea); + + const actions = document.createElement("div"); + actions.className = "profile-actions"; + const moveTopBtn = document.createElement("button"); + moveTopBtn.type = "button"; + moveTopBtn.className = "ghost move-top"; + moveTopBtn.textContent = "Top"; + const moveUpBtn = document.createElement("button"); + moveUpBtn.type = "button"; + moveUpBtn.className = "ghost move-up"; + moveUpBtn.textContent = "Up"; + const moveDownBtn = document.createElement("button"); + moveDownBtn.type = "button"; + moveDownBtn.className = "ghost move-down"; + moveDownBtn.textContent = "Down"; + const addBelowBtn = document.createElement("button"); + addBelowBtn.type = "button"; + addBelowBtn.className = "ghost add-below"; + addBelowBtn.textContent = "Add"; + + moveTopBtn.addEventListener("click", () => { + const first = profilesContainer.firstElementChild; + if (!first || first === card) return; + profilesContainer.insertBefore(card, first); + updateProfileControls(); + updateTaskProfileOptions(); + }); + + moveUpBtn.addEventListener("click", () => { + const previous = card.previousElementSibling; + if (!previous) return; + profilesContainer.insertBefore(card, previous); + updateProfileControls(); + updateTaskProfileOptions(); + }); + + moveDownBtn.addEventListener("click", () => { + const next = card.nextElementSibling; + if (!next) return; + profilesContainer.insertBefore(card, next.nextElementSibling); + updateProfileControls(); + updateTaskProfileOptions(); + }); + + actions.appendChild(moveTopBtn); + actions.appendChild(moveUpBtn); + actions.appendChild(moveDownBtn); + actions.appendChild(addBelowBtn); + + addBelowBtn.addEventListener("click", () => { + const name = buildUniqueDefaultName( + collectNames(profilesContainer, ".profile-name") + ); + const newCard = buildProfileCard({ + id: newProfileId(), + name, + text: "", + type: "Resume" + }); + card.insertAdjacentElement("afterend", newCard); + updateProfileControls(); + updateTaskProfileOptions(); + }); + + const duplicateBtn = document.createElement("button"); + duplicateBtn.type = "button"; + 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) || { + id: card.dataset.id, + name: nameInput.value || "Default", + text: textArea.value || "", + type: typeSelect.value || "Resume" + }; + const newCard = buildProfileCard({ + id: newProfileId(), + name: ensureUniqueName(`${copy.name || "Default"} Copy`, names), + text: copy.text, + type: copy.type || "Resume" + }); + card.insertAdjacentElement("afterend", newCard); + updateProfileControls(); + updateTaskProfileOptions(); + }); + + const deleteBtn = document.createElement("button"); + deleteBtn.type = "button"; + deleteBtn.className = "ghost delete"; + deleteBtn.textContent = "Delete"; + deleteBtn.addEventListener("click", () => { + card.remove(); + updateProfileControls(); + updateTaskProfileOptions(); + }); + + 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")]; + 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" + }; + }); +} + +function updateProfileControls() { + const cards = [...profilesContainer.querySelectorAll(".profile-card")]; + cards.forEach((card, index) => { + const moveTopBtn = card.querySelector(".move-top"); + const moveUpBtn = card.querySelector(".move-up"); + const moveDownBtn = card.querySelector(".move-down"); + if (moveTopBtn) moveTopBtn.disabled = index === 0; + if (moveUpBtn) moveUpBtn.disabled = index === 0; + if (moveDownBtn) moveDownBtn.disabled = index === cards.length - 1; + }); + scheduleSidebarErrors(); +} + +function updateTaskProfileOptions() { + const profiles = collectProfiles(); + const selects = tasksContainer.querySelectorAll(".task-profile-select"); + selects.forEach((select) => { + const preferred = select.dataset.preferred || select.value; + select.innerHTML = ""; + if (!profiles.length) { + const option = document.createElement("option"); + option.value = ""; + option.textContent = "No profiles configured"; + select.appendChild(option); + select.disabled = true; + return; + } + + select.disabled = false; + for (const profile of profiles) { + const option = document.createElement("option"); + option.value = profile.id; + option.textContent = profile.name || "Default"; + select.appendChild(option); + } + + if (preferred && profiles.some((profile) => profile.id === preferred)) { + select.value = preferred; + } else { + select.value = profiles[0].id; + } + + select.dataset.preferred = select.value; + }); + scheduleSidebarErrors(); +} + function updateEnvApiOptions() { const apiConfigs = collectApiConfigs(); const selects = envConfigsContainer.querySelectorAll(".env-config-api-select"); @@ -810,6 +1124,16 @@ function buildTaskCard(task) { envField.appendChild(envLabel); envField.appendChild(envSelect); + const profileField = document.createElement("div"); + profileField.className = "field"; + const profileLabel = document.createElement("label"); + profileLabel.textContent = "Default profile"; + const profileSelect = document.createElement("select"); + profileSelect.className = "task-profile-select"; + profileSelect.dataset.preferred = task.defaultProfileId || ""; + profileField.appendChild(profileLabel); + profileField.appendChild(profileSelect); + const textField = document.createElement("div"); textField.className = "field"; const textLabel = document.createElement("label"); @@ -889,11 +1213,13 @@ function buildTaskCard(task) { id: newTaskId(), name, text: "", - defaultEnvId: getTopEnvId() + defaultEnvId: getTopEnvId(), + defaultProfileId: getTopProfileId() }); card.insertAdjacentElement("afterend", newCard); updateTaskControls(); updateTaskEnvOptions(); + updateTaskProfileOptions(); }); duplicateBtn.addEventListener("click", () => { @@ -904,12 +1230,14 @@ function buildTaskCard(task) { collectNames(tasksContainer, ".task-name") ), text: textArea.value, - defaultEnvId: envSelect.value || "" + defaultEnvId: envSelect.value || "", + defaultProfileId: profileSelect.value || "" }; const newCard = buildTaskCard(copy); card.insertAdjacentElement("afterend", newCard); updateTaskControls(); updateTaskEnvOptions(); + updateTaskProfileOptions(); }); deleteBtn.addEventListener("click", () => { @@ -926,6 +1254,7 @@ function buildTaskCard(task) { card.appendChild(nameField); card.appendChild(envField); + card.appendChild(profileField); card.appendChild(textField); card.appendChild(actions); @@ -942,6 +1271,7 @@ function updateTaskControls() { if (moveUpBtn) moveUpBtn.disabled = index === 0; if (moveDownBtn) moveDownBtn.disabled = index === cards.length - 1; }); + scheduleSidebarErrors(); } function collectTasks() { @@ -950,15 +1280,127 @@ function collectTasks() { const nameInput = card.querySelector(".task-name"); const textArea = card.querySelector(".task-text"); const envSelect = card.querySelector(".task-env-select"); + const profileSelect = card.querySelector(".task-profile-select"); return { id: card.dataset.id || newTaskId(), name: (nameInput?.value || "Untitled Task").trim(), text: (textArea?.value || "").trim(), - defaultEnvId: envSelect?.value || "" + defaultEnvId: envSelect?.value || "", + defaultProfileId: profileSelect?.value || "" }; }); } +function updateSidebarErrors() { + if (!sidebarErrorsEl) return; + const errors = []; + + const tasks = collectTasks(); + const envs = collectEnvConfigs(); + const profiles = collectProfiles(); + const apiConfigs = collectApiConfigs(); + const apiKeys = collectApiKeys(); + + const checkNameInputs = (container, selector, label) => { + if (!container) return; + const inputs = [...container.querySelectorAll(selector)]; + if (!inputs.length) return; + const seen = new Map(); + let hasEmpty = false; + for (const input of inputs) { + const name = (input.value || "").trim(); + if (!name) { + hasEmpty = true; + continue; + } + const lower = name.toLowerCase(); + seen.set(lower, (seen.get(lower) || 0) + 1); + } + if (hasEmpty) { + errors.push(`${label} has empty names.`); + } + for (const [name, count] of seen.entries()) { + if (count > 1) { + errors.push(`${label} has duplicate name: ${name}.`); + } + } + }; + + checkNameInputs(tasksContainer, ".task-name", "Task presets"); + checkNameInputs(envConfigsContainer, ".env-config-name", "Environments"); + checkNameInputs(profilesContainer, ".profile-name", "Profiles"); + checkNameInputs(apiConfigsContainer, ".api-config-name", "API configs"); + checkNameInputs(apiKeysContainer, ".api-key-name", "API keys"); + + if (!tasks.length) errors.push("No task presets configured."); + if (!envs.length) errors.push("No environments configured."); + if (!profiles.length) errors.push("No profiles configured."); + if (!apiConfigs.length) errors.push("No API configs configured."); + if (!apiKeys.length) errors.push("No API keys configured."); + + if (tasks.length) { + const defaultTask = tasks[0]; + if (!defaultTask.text) errors.push("Default task prompt is empty."); + + const defaultEnv = + envs.find((env) => env.id === defaultTask.defaultEnvId) || envs[0]; + if (!defaultEnv) { + errors.push("Default task environment is missing."); + } + + const defaultProfile = + profiles.find((profile) => profile.id === defaultTask.defaultProfileId) || + profiles[0]; + if (!defaultProfile) { + errors.push("Default task profile is missing."); + } else if (!defaultProfile.text) { + errors.push("Default profile text is empty."); + } + + const defaultApiConfig = defaultEnv + ? apiConfigs.find((config) => config.id === defaultEnv.apiConfigId) + : null; + if (!defaultApiConfig) { + errors.push("Default environment is missing an API config."); + } else if (defaultApiConfig.advanced) { + if (!defaultApiConfig.apiUrl) { + errors.push("Default API config is missing an API URL."); + } + if (!defaultApiConfig.requestTemplate) { + errors.push("Default API config is missing a request template."); + } + } else { + if (!defaultApiConfig.apiBaseUrl) { + errors.push("Default API config is missing a base URL."); + } + if (!defaultApiConfig.model) { + errors.push("Default API config is missing a model name."); + } + } + + const needsKey = + Boolean(defaultApiConfig?.apiKeyHeader) || + Boolean( + defaultApiConfig?.requestTemplate?.includes("API_KEY_GOES_HERE") + ); + if (needsKey) { + const key = apiKeys.find((entry) => entry.id === defaultApiConfig?.apiKeyId); + if (!key || !key.key) { + errors.push("Default API config is missing an API key."); + } + } + } + + if (!errors.length) { + sidebarErrorsEl.classList.add("hidden"); + sidebarErrorsEl.textContent = ""; + return; + } + + sidebarErrorsEl.textContent = errors.map((error) => `- ${error}`).join("\n"); + sidebarErrorsEl.classList.remove("hidden"); +} + async function loadSettings() { const { apiKey = "", @@ -968,6 +1410,7 @@ async function loadSettings() { activeApiConfigId = "", envConfigs = [], activeEnvConfigId = "", + profiles = [], apiBaseUrl = "", apiKeyHeader = "", apiKeyPrefix = "", @@ -984,6 +1427,7 @@ async function loadSettings() { "activeApiConfigId", "envConfigs", "activeEnvConfigId", + "profiles", "apiBaseUrl", "apiKeyHeader", "apiKeyPrefix", @@ -994,7 +1438,6 @@ async function loadSettings() { "theme" ]); - resumeInput.value = resume; themeSelect.value = theme; applyTheme(theme); @@ -1130,18 +1573,55 @@ async function loadSettings() { updateEnvApiOptions(); updateEnvControls(); + let resolvedProfiles = Array.isArray(profiles) ? profiles : []; + if (!resolvedProfiles.length) { + const migrated = { + id: newProfileId(), + name: "Default", + text: resume || "", + type: "Resume" + }; + resolvedProfiles = [migrated]; + await chrome.storage.local.set({ profiles: resolvedProfiles }); + } else { + const normalized = resolvedProfiles.map((profile) => ({ + ...profile, + text: profile.text ?? "", + type: profile.type === "Profile" ? "Profile" : "Resume" + })); + const needsUpdate = normalized.some( + (profile, index) => + (profile.text || "") !== (resolvedProfiles[index]?.text || "") || + (profile.type || "Resume") !== (resolvedProfiles[index]?.type || "Resume") + ); + if (needsUpdate) { + resolvedProfiles = normalized; + await chrome.storage.local.set({ profiles: resolvedProfiles }); + } + } + + profilesContainer.innerHTML = ""; + for (const profile of resolvedProfiles) { + profilesContainer.appendChild(buildProfileCard(profile)); + } + updateProfileControls(); + tasksContainer.innerHTML = ""; const defaultEnvId = resolvedEnvConfigs[0]?.id || ""; + const defaultProfileId = resolvedProfiles[0]?.id || ""; const normalizedTasks = Array.isArray(tasks) ? tasks.map((task) => ({ ...task, - defaultEnvId: task.defaultEnvId || defaultEnvId + defaultEnvId: task.defaultEnvId || defaultEnvId, + defaultProfileId: task.defaultProfileId || defaultProfileId })) : []; if ( normalizedTasks.length && normalizedTasks.some( - (task, index) => task.defaultEnvId !== tasks[index]?.defaultEnvId + (task, index) => + task.defaultEnvId !== tasks[index]?.defaultEnvId || + task.defaultProfileId !== tasks[index]?.defaultProfileId ) ) { await chrome.storage.local.set({ tasks: normalizedTasks }); @@ -1153,11 +1633,13 @@ async function loadSettings() { id: newTaskId(), name: "", text: "", - defaultEnvId + defaultEnvId, + defaultProfileId }) ); updateTaskControls(); updateTaskEnvOptions(); + updateTaskProfileOptions(); return; } @@ -1166,6 +1648,8 @@ async function loadSettings() { } updateTaskControls(); updateTaskEnvOptions(); + updateTaskProfileOptions(); + updateSidebarErrors(); } async function saveSettings() { @@ -1173,6 +1657,7 @@ async function saveSettings() { const apiKeys = collectApiKeys(); const apiConfigs = collectApiConfigs(); const envConfigs = collectEnvConfigs(); + const profiles = collectProfiles(); const activeEnvConfigId = envConfigs[0]?.id || ""; const activeEnv = envConfigs[0]; const activeApiConfigId = @@ -1190,7 +1675,8 @@ async function saveSettings() { envConfigs, activeEnvConfigId, systemPrompt: activeEnv?.systemPrompt || "", - resume: resumeInput.value, + profiles, + resume: profiles[0]?.text || "", tasks, theme: themeSelect.value }); @@ -1198,6 +1684,9 @@ async function saveSettings() { } saveBtn.addEventListener("click", () => void saveSettings()); +if (saveBtnSidebar) { + saveBtnSidebar.addEventListener("click", () => void saveSettings()); +} addTaskBtn.addEventListener("click", () => { const name = buildUniqueDefaultName( collectNames(tasksContainer, ".task-name") @@ -1206,7 +1695,8 @@ addTaskBtn.addEventListener("click", () => { id: newTaskId(), name, text: "", - defaultEnvId: getTopEnvId() + defaultEnvId: getTopEnvId(), + defaultProfileId: getTopProfileId() }); const first = tasksContainer.firstElementChild; if (first) { @@ -1216,6 +1706,7 @@ addTaskBtn.addEventListener("click", () => { } updateTaskControls(); updateTaskEnvOptions(); + updateTaskProfileOptions(); }); addApiKeyBtn.addEventListener("click", () => { @@ -1281,7 +1772,41 @@ addEnvConfigBtn.addEventListener("click", () => { updateTaskEnvOptions(); }); +addProfileBtn.addEventListener("click", () => { + const name = buildUniqueDefaultName( + collectNames(profilesContainer, ".profile-name") + ); + const newCard = buildProfileCard({ + id: newProfileId(), + name, + text: "", + type: "Resume" + }); + const first = profilesContainer.firstElementChild; + if (first) { + profilesContainer.insertBefore(newCard, first); + } else { + profilesContainer.appendChild(newCard); + } + updateProfileControls(); + updateTaskProfileOptions(); +}); + 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; + } + }); +}); + +document.addEventListener("input", scheduleSidebarErrors); +document.addEventListener("change", scheduleSidebarErrors);