gemini handoff to codex

This commit is contained in:
2026-01-18 04:45:32 -05:00
parent a02c6ed1db
commit 6106adbc6f
9 changed files with 1389 additions and 451 deletions

View File

@@ -28,9 +28,9 @@ const DEFAULT_SETTINGS = {
model: "gpt-4o-mini",
systemPrompt:
"You are a precise, honest assistant. Be concise and avoid inventing details, be critical about evaluations. You should put in a small summary of all the sections at the end. You should answer in no longer than 3 sections including the summary. And remember to bold or italicize key points.",
resume: "",
tasks: DEFAULT_TASKS,
theme: "system"
theme: "system",
workspaces: []
};
const OUTPUT_STORAGE_KEY = "lastOutput";
@@ -54,7 +54,7 @@ function resetAbort() {
function openKeepalive(tabId) {
if (!tabId || keepalivePort) return;
try {
keepalivePort = chrome.tabs.connect(tabId, { name: "wwcompanion-keepalive" });
keepalivePort = chrome.tabs.connect(tabId, { name: "sitecompanion-keepalive" });
keepalivePort.onDisconnect.addListener(() => {
keepalivePort = null;
});
@@ -253,20 +253,17 @@ chrome.runtime.onInstalled.addListener(async () => {
{
id,
name: "Default",
text: stored.resume || "",
type: "Resume"
text: stored.resume || ""
}
];
} else {
const normalizedProfiles = stored.profiles.map((profile) => ({
...profile,
text: profile.text ?? "",
type: profile.type === "Profile" ? "Profile" : "Resume"
text: profile.text ?? ""
}));
const needsProfileUpdate = normalizedProfiles.some(
(profile, index) =>
(profile.text || "") !== (stored.profiles[index]?.text || "") ||
(profile.type || "Resume") !== (stored.profiles[index]?.type || "Resume")
(profile.text || "") !== (stored.profiles[index]?.text || "")
);
if (needsProfileUpdate) {
updates.profiles = normalizedProfiles;
@@ -364,17 +361,16 @@ chrome.runtime.onMessage.addListener((message) => {
}
});
function buildUserMessage(resume, resumeType, task, posting) {
const header = resumeType === "Profile" ? "=== PROFILE ===" : "=== RESUME ===";
function buildUserMessage(profileText, taskText, siteText) {
return [
header,
resume || "",
"=== Profile ===",
profileText || "",
"",
"=== TASK ===",
task || "",
"=== Task ===",
taskText || "",
"",
"=== JOB POSTING ===",
posting || ""
"=== Site Text ===",
siteText || ""
].join("\n");
}
@@ -406,10 +402,9 @@ async function handleAnalysisRequest(port, payload, signal) {
apiKeyPrefix,
model,
systemPrompt,
resume,
resumeType,
profileText,
taskText,
postingText,
siteText,
tabId
} = payload || {};
@@ -444,21 +439,20 @@ async function handleAnalysisRequest(port, payload, signal) {
}
}
if (!postingText) {
safePost(port, { type: "ERROR", message: "No job posting text provided." });
if (!siteText) {
safePost(port, { type: "ERROR", message: "No site text provided." });
return;
}
if (!taskText) {
safePost(port, { type: "ERROR", message: "No task prompt selected." });
safePost(port, { type: "ERROR", message: "No task selected." });
return;
}
const userMessage = buildUserMessage(
resume,
resumeType,
profileText,
taskText,
postingText
siteText
);
await chrome.storage.local.set({ [OUTPUT_STORAGE_KEY]: "" });

View File

@@ -1,140 +1,143 @@
const HEADER_LINES = new Set([
"OVERVIEW",
"PRE-SCREENING",
"WORK TERM RATINGS",
"JOB POSTING INFORMATION",
"APPLICATION INFORMATION",
"COMPANY INFORMATION",
"SERVICE TEAM"
]);
function findMinimumScope(text) {
if (!text) return null;
const normalized = text.trim();
if (!normalized) return null;
const ACTION_BAR_SELECTOR = "nav.floating--action-bar";
const INJECTED_ATTR = "data-wwcompanion-default-task";
const DEFAULT_TASK_LABEL = "Default WWCompanion Task";
function isJobPostingOpen() {
return document.getElementsByClassName("modal__content").length > 0;
}
function sanitizePostingText(text) {
let cleaned = text.replaceAll("fiber_manual_record", "");
const lines = cleaned.split(/\r?\n/);
const filtered = lines.filter((line) => {
const trimmed = line.trim();
if (!trimmed) return true;
return !HEADER_LINES.has(trimmed.toUpperCase());
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT, {
acceptNode: (node) => {
if (node.innerText.includes(normalized)) {
return NodeFilter.FILTER_ACCEPT;
}
return NodeFilter.FILTER_REJECT;
}
});
cleaned = filtered.join("\n");
cleaned = cleaned.replace(/[ \t]+/g, " ");
cleaned = cleaned.replace(/\n{3,}/g, "\n\n");
return cleaned.trim();
}
function buildDefaultTaskButton(templateButton) {
const button = document.createElement("button");
button.type = "button";
button.textContent = DEFAULT_TASK_LABEL;
button.className = templateButton.className;
button.setAttribute(INJECTED_ATTR, "true");
button.setAttribute("aria-label", DEFAULT_TASK_LABEL);
button.addEventListener("click", () => {
document.dispatchEvent(new CustomEvent("WWCOMPANION_RUN_DEFAULT_TASK"));
chrome.runtime.sendMessage({ type: "RUN_DEFAULT_TASK" });
});
return button;
}
function getActionBars() {
return [...document.querySelectorAll(ACTION_BAR_SELECTOR)];
}
function getActionBarButtonCount(bar) {
return bar.querySelectorAll(`button:not([${INJECTED_ATTR}])`).length;
}
function selectTargetActionBar(bars) {
if (!bars.length) return null;
let best = bars[0];
let bestCount = getActionBarButtonCount(best);
for (const bar of bars.slice(1)) {
const count = getActionBarButtonCount(bar);
if (count > bestCount) {
best = bar;
bestCount = count;
}
}
return best;
}
function ensureDefaultTaskButton() {
const bars = getActionBars();
if (!bars.length) return;
if (!isJobPostingOpen()) {
for (const bar of bars) {
const injected = bar.querySelector(`[${INJECTED_ATTR}]`);
if (injected) injected.remove();
}
return;
let deepest = null;
let node = walker.nextNode();
while (node) {
deepest = node;
node = walker.nextNode();
}
const toolbar = selectTargetActionBar(bars);
if (!toolbar) return;
return deepest;
}
for (const bar of bars) {
if (bar === toolbar) continue;
const injected = bar.querySelector(`[${INJECTED_ATTR}]`);
if (injected) injected.remove();
function createToolbar(presets, position = "bottom-right") {
let toolbar = document.getElementById("sitecompanion-toolbar");
if (toolbar) toolbar.remove();
toolbar = document.createElement("div");
toolbar.id = "sitecompanion-toolbar";
let posStyle = "";
switch (position) {
case "top-left":
posStyle = "top: 20px; left: 20px;";
break;
case "top-right":
posStyle = "top: 20px; right: 20px;";
break;
case "bottom-left":
posStyle = "bottom: 20px; left: 20px;";
break;
case "bottom-center":
posStyle = "bottom: 20px; left: 50%; transform: translateX(-50%);";
break;
case "bottom-right":
default:
posStyle = "bottom: 20px; right: 20px;";
break;
}
const existing = toolbar.querySelector(`[${INJECTED_ATTR}]`);
if (existing) return;
toolbar.style.cssText = `
position: fixed;
${posStyle}
background: #fff7ec;
border: 1px solid #e4d6c5;
border-radius: 12px;
padding: 8px;
box-shadow: 0 8px 24px rgba(0,0,0,0.15);
z-index: 999999;
display: flex;
gap: 8px;
font-family: system-ui, sans-serif;
`;
const templateButton = toolbar.querySelector("button");
if (!templateButton) return;
const button = buildDefaultTaskButton(templateButton);
const firstChild = toolbar.firstElementChild;
if (firstChild) {
toolbar.insertBefore(button, firstChild);
if (!presets || !presets.length) {
const label = document.createElement("span");
label.textContent = "SiteCompanion";
label.style.fontSize = "12px";
label.style.color = "#6b5f55";
toolbar.appendChild(label);
} else {
toolbar.appendChild(button);
for (const preset of presets) {
const btn = document.createElement("button");
btn.textContent = preset.name;
btn.style.cssText = `
padding: 6px 12px;
background: #b14d2b;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 12px;
`;
btn.addEventListener("click", () => {
chrome.runtime.sendMessage({ type: "RUN_PRESET", presetId: preset.id });
});
toolbar.appendChild(btn);
}
}
document.body.appendChild(toolbar);
}
function matchUrl(url, pattern) {
if (!pattern) return false;
const regex = new RegExp("^" + pattern.split("*").join(".*") + "$");
try {
const urlObj = new URL(url);
const target = urlObj.hostname + urlObj.pathname;
return regex.test(target);
} catch {
return false;
}
}
async function refreshToolbar() {
const { sites = [], presets = [], toolbarPosition = "bottom-right" } = await chrome.storage.local.get(["sites", "presets", "toolbarPosition"]);
const currentUrl = window.location.href;
const site = sites.find(s => matchUrl(currentUrl, s.urlPattern));
if (site) {
createToolbar(presets, toolbarPosition);
}
}
function extractPostingText() {
const contents = [...document.getElementsByClassName("modal__content")];
if (!contents.length) {
return { ok: false, error: "No modal content found on this page." };
}
// WaterlooWorks renders multiple modal containers; choose the longest visible text block.
const el = contents.reduce((best, cur) =>
cur.innerText.length > best.innerText.length ? cur : best
);
const rawText = el.innerText;
const sanitized = sanitizePostingText(rawText);
return { ok: true, rawText, sanitized };
}
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (message?.type !== "EXTRACT_POSTING") return;
const result = extractPostingText();
sendResponse(result);
});
chrome.runtime.onConnect.addListener((port) => {
if (port.name !== "wwcompanion-keepalive") return;
port.onDisconnect.addListener(() => {});
});
const observer = new MutationObserver(() => {
ensureDefaultTaskButton();
// refreshToolbar(); // Debounce this?
});
observer.observe(document.documentElement, { childList: true, subtree: true });
ensureDefaultTaskButton();
// observer.observe(document.documentElement, { childList: true, subtree: true });
refreshToolbar();

View File

@@ -1,12 +1,12 @@
{
"manifest_version": 3,
"name": "WWCompanion",
"version": "0.3.1",
"description": "AI companion for WaterlooWorks job postings.",
"name": "SiteCompanion",
"version": "0.4.0",
"description": "AI companion for site-bound text extraction and tasks.",
"permissions": ["storage", "activeTab"],
"host_permissions": ["https://waterlooworks.uwaterloo.ca/*"],
"host_permissions": ["<all_urls>"],
"action": {
"default_title": "WWCompanion",
"default_title": "SiteCompanion",
"default_popup": "popup.html"
},
"background": {
@@ -15,7 +15,7 @@
},
"content_scripts": [
{
"matches": ["https://waterlooworks.uwaterloo.ca/*"],
"matches": ["<all_urls>"],
"js": ["content.js"]
}
],

View File

@@ -158,7 +158,63 @@ select {
}
.hidden {
display: none;
display: none !important;
}
.state-body {
display: grid;
gap: 10px;
}
.state-body p {
margin: 0;
font-size: 12px;
line-height: 1.4;
}
textarea#partialTextPaste {
width: 100%;
padding: 8px;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--input-bg);
color: var(--input-fg);
font-size: 12px;
resize: vertical;
}
.preview-box {
max-height: 120px;
overflow-y: auto;
padding: 8px;
border-radius: 10px;
background: var(--code-bg);
font-size: 11px;
white-space: pre-wrap;
border: 1px solid var(--border);
}
.row {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.workspace-info {
font-size: 11px;
color: var(--muted);
margin-bottom: 8px;
font-style: italic;
}
input[type="text"] {
width: 100%;
padding: 6px 8px;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--input-bg);
color: var(--input-fg);
font-size: 12px;
}
button {

View File

@@ -3,18 +3,46 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>WWCompanion</title>
<title>SiteCompanion</title>
<link rel="stylesheet" href="popup.css" />
</head>
<body>
<header class="title-block">
<div class="title-line">
<span class="title">WWCompanion</span>
<span class="subtitle">AI companion for WaterlooWorks.</span>
<span class="title">SiteCompanion</span>
<span class="subtitle">AI companion for site-bound tasks.</span>
</div>
</header>
<section class="panel">
<section id="unknownSiteState" class="panel hidden">
<div class="state-body">
<p>This site is not recognized. Paste partial text from the page you want to extract:</p>
<textarea id="partialTextPaste" rows="4" placeholder="Paste some text here..."></textarea>
<div class="row">
<button id="extractFullBtn" class="accent">Try Extracting Full Text</button>
</div>
</div>
</section>
<section id="extractionReviewState" class="panel hidden">
<div class="state-body">
<p>Review extracted text:</p>
<div id="extractedPreview" class="preview-box"></div>
<div class="field">
<label for="urlPatternInput">URL Match Pattern</label>
<input type="text" id="urlPatternInput" placeholder="example.com/*" />
</div>
<div class="row">
<button id="retryExtractBtn" class="ghost">Retry</button>
<button id="confirmSiteBtn" class="accent">Confirm</button>
</div>
</div>
</section>
<section id="normalExecutionState" class="panel">
<div class="workspace-info">
Workspace: <span id="currentWorkspaceName">Global</span>
</div>
<div class="controls-block">
<div class="config-block">
<div class="selector-row">
@@ -38,8 +66,8 @@
</div>
</div>
<div class="meta">
<span id="postingCount">Posting: 0 chars</span>
<span id="promptCount">Prompt: 0 chars</span>
<span id="postingCount">Site Text: 0 chars</span>
<span id="promptCount">Task: 0 chars</span>
<span id="status" class="status">Idle</span>
</div>
</section>

View File

@@ -18,11 +18,26 @@ const LAST_TASK_KEY = "lastSelectedTaskId";
const LAST_ENV_KEY = "lastSelectedEnvId";
const LAST_PROFILE_KEY = "lastSelectedProfileId";
const unknownSiteState = document.getElementById("unknownSiteState");
const extractionReviewState = document.getElementById("extractionReviewState");
const normalExecutionState = document.getElementById("normalExecutionState");
const partialTextPaste = document.getElementById("partialTextPaste");
const extractFullBtn = document.getElementById("extractFullBtn");
const extractedPreview = document.getElementById("extractedPreview");
const urlPatternInput = document.getElementById("urlPatternInput");
const retryExtractBtn = document.getElementById("retryExtractBtn");
const confirmSiteBtn = document.getElementById("confirmSiteBtn");
const currentWorkspaceName = document.getElementById("currentWorkspaceName");
const state = {
postingText: "",
siteText: "",
tasks: [],
envs: [],
profiles: [],
sites: [],
workspaces: [],
currentSite: null,
currentWorkspace: null,
port: null,
isAnalyzing: false,
outputRaw: "",
@@ -32,21 +47,65 @@ const state = {
selectedProfileId: ""
};
async function switchState(stateName) {
unknownSiteState.classList.add("hidden");
extractionReviewState.classList.add("hidden");
normalExecutionState.classList.add("hidden");
if (stateName === "unknown") {
unknownSiteState.classList.remove("hidden");
} else if (stateName === "review") {
extractionReviewState.classList.remove("hidden");
} else if (stateName === "normal") {
normalExecutionState.classList.remove("hidden");
}
await chrome.storage.local.set({ lastPopupState: stateName });
}
function matchUrl(url, pattern) {
if (!pattern) return false;
const regex = new RegExp("^" + pattern.split("*").join(".*") + "$");
try {
const urlObj = new URL(url);
const target = urlObj.hostname + urlObj.pathname;
return regex.test(target);
} catch {
return false;
}
}
async function detectSite(url) {
const { sites = [], workspaces = [] } = await getStorage(["sites", "workspaces"]);
state.sites = sites;
state.workspaces = workspaces;
const site = sites.find(s => matchUrl(url, s.urlPattern));
if (site) {
state.currentSite = site;
state.currentWorkspace = workspaces.find(w => w.id === site.workspaceId) || { name: "Global", id: "global" };
currentWorkspaceName.textContent = state.currentWorkspace.name;
switchState("normal");
return true;
}
switchState("unknown");
return false;
}
function getStorage(keys) {
return new Promise((resolve) => chrome.storage.local.get(keys, resolve));
}
function buildUserMessage(resume, resumeType, task, posting) {
const header = resumeType === "Profile" ? "=== PROFILE ===" : "=== RESUME ===";
function buildUserMessage(profileText, taskText, siteText) {
return [
header,
resume || "",
"=== Profile ===",
profileText || "",
"",
"=== TASK ===",
task || "",
"=== Task ===",
taskText || "",
"",
"=== JOB POSTING ===",
posting || ""
"=== Site Text ===",
siteText || ""
].join("\n");
}
@@ -261,12 +320,12 @@ function setAnalyzing(isAnalyzing) {
updateProfileSelectState();
}
function updatePostingCount() {
postingCountEl.textContent = `Posting: ${state.postingText.length} chars`;
function updateSiteTextCount() {
postingCountEl.textContent = `Site Text: ${state.siteText.length} chars`;
}
function updatePromptCount(count) {
promptCountEl.textContent = `Prompt: ${count} chars`;
promptCountEl.textContent = `Task: ${count} chars`;
}
function renderTasks(tasks) {
@@ -399,14 +458,6 @@ async function persistSelections() {
});
}
function isWaterlooWorksUrl(url) {
try {
return new URL(url).hostname === "waterlooworks.uwaterloo.ca";
} catch {
return false;
}
}
function sendToActiveTab(message) {
return new Promise((resolve, reject) => {
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
@@ -416,17 +467,12 @@ function sendToActiveTab(message) {
return;
}
if (!isWaterlooWorksUrl(tab.url || "")) {
reject(new Error("Open waterlooworks.uwaterloo.ca to use this."));
return;
}
chrome.tabs.sendMessage(tab.id, message, (response) => {
const error = chrome.runtime.lastError;
if (error) {
const msg =
error.message && error.message.includes("Receiving end does not exist")
? "Couldn't reach the page. Try refreshing WaterlooWorks and retry."
? "Couldn't reach the page. Try refreshing and retry."
: error.message;
reject(new Error(msg));
return;
@@ -486,6 +532,22 @@ function ensurePort() {
}
async function loadConfig() {
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
const currentUrl = tabs[0]?.url || "";
const { lastPopupState } = await getStorage(["lastPopupState"]);
await detectSite(currentUrl);
if (lastPopupState && lastPopupState !== "unknown") {
// If we had a state like 'review', we might want to stay there,
// but detectSite might have switched to 'normal' if it matched.
// AGENTS.md says popup state must be persisted.
if (state.currentSite && lastPopupState === "normal") {
await switchState("normal");
} else if (!state.currentSite && (lastPopupState === "unknown" || lastPopupState === "review")) {
await switchState(lastPopupState);
}
}
const stored = await getStorage([
"tasks",
"envConfigs",
@@ -548,40 +610,40 @@ async function loadTheme() {
async function handleExtract() {
setStatus("Extracting...");
try {
const response = await sendToActiveTab({ type: "EXTRACT_POSTING" });
const response = await sendToActiveTab({ type: "EXTRACT_FULL" });
if (!response?.ok) {
setStatus(response?.error || "No posting detected.");
setStatus(response?.error || "No text detected.");
return false;
}
state.postingText = response.sanitized || "";
updatePostingCount();
state.siteText = response.extracted || "";
updateSiteTextCount();
updatePromptCount(0);
setStatus("Posting extracted.");
setStatus("Text extracted.");
return true;
} catch (error) {
setStatus(error.message || "Unable to extract posting.");
setStatus(error.message || "Unable to extract text.");
return false;
}
}
async function handleAnalyze() {
if (!state.postingText) {
setStatus("Extract a job posting first.");
if (!state.siteText) {
setStatus("Extract site text first.");
return;
}
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
const tab = tabs[0];
if (!tab?.url || !isWaterlooWorksUrl(tab.url)) {
setStatus("Open waterlooworks.uwaterloo.ca to run tasks.");
if (!tab?.url) {
setStatus("Open a page to run tasks.");
return;
}
const taskId = taskSelect.value;
const task = state.tasks.find((item) => item.id === taskId);
if (!task) {
setStatus("Select a task prompt.");
setStatus("Select a task.");
return;
}
@@ -640,8 +702,7 @@ async function handleAnalyze() {
const activeProfile =
resolvedProfiles.find((entry) => entry.id === selectedProfileId) ||
resolvedProfiles[0];
const resumeText = activeProfile?.text || resume || "";
const resumeType = activeProfile?.type || "Resume";
const profileText = activeProfile?.text || resume || "";
const isAdvanced = Boolean(activeConfig?.advanced);
const resolvedApiUrl = activeConfig?.apiUrl || "";
const resolvedTemplate = activeConfig?.requestTemplate || "";
@@ -688,10 +749,9 @@ async function handleAnalyze() {
}
const promptText = buildUserMessage(
resumeText,
resumeType,
profileText,
task.text || "",
state.postingText
state.siteText
);
updatePromptCount(promptText.length);
@@ -713,10 +773,9 @@ async function handleAnalyze() {
apiKeyPrefix: resolvedApiKeyPrefix,
model: resolvedModel,
systemPrompt: resolvedSystemPrompt,
resume: resumeText,
resumeType,
profileText,
taskText: task.text || "",
postingText: state.postingText,
siteText: state.siteText,
tabId: tab.id
}
});
@@ -769,6 +828,81 @@ function handleCopyRaw() {
void copyTextToClipboard(text, "Markdown");
}
partialTextPaste.addEventListener("input", async () => {
const text = partialTextPaste.value.trim();
if (text.length < 5) return;
setStatus("Finding scope...");
try {
const response = await sendToActiveTab({ type: "FIND_SCOPE", text });
if (response?.ok) {
state.siteText = response.extracted;
extractedPreview.textContent = state.siteText;
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
const url = new URL(tabs[0].url);
urlPatternInput.value = url.hostname + url.pathname + "*";
switchState("review");
setStatus("Review extraction.");
}
} catch (error) {
setStatus("Error finding scope.");
}
});
extractFullBtn.addEventListener("click", async () => {
setStatus("Extracting full text...");
try {
const response = await sendToActiveTab({ type: "EXTRACT_FULL" });
if (response?.ok) {
state.siteText = response.extracted;
extractedPreview.textContent = state.siteText;
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
const url = new URL(tabs[0].url);
urlPatternInput.value = url.hostname + url.pathname + "*";
switchState("review");
setStatus("Review extraction.");
}
} catch (error) {
setStatus("Error extracting text.");
}
});
retryExtractBtn.addEventListener("click", () => {
switchState("unknown");
partialTextPaste.value = "";
setStatus("Ready.");
});
confirmSiteBtn.addEventListener("click", async () => {
const pattern = urlPatternInput.value.trim();
if (!pattern) {
setStatus("Enter a URL pattern.");
return;
}
// AGENTS.md: No URL pattern may be a substring of another.
const conflict = state.sites.find(s => s.urlPattern.includes(pattern) || pattern.includes(s.urlPattern));
if (conflict) {
setStatus("URL pattern conflict.");
return;
}
const newSite = {
id: `site-${Date.now()}`,
urlPattern: pattern,
workspaceId: "global" // Default to global for now
};
state.sites.push(newSite);
await chrome.storage.local.set({ sites: state.sites });
state.currentSite = newSite;
state.currentWorkspace = { name: "Global", id: "global" };
currentWorkspaceName.textContent = "Global";
switchState("normal");
updateSiteTextCount();
setStatus("Site saved.");
});
runBtn.addEventListener("click", handleExtractAndAnalyze);
abortBtn.addEventListener("click", handleAbort);
settingsBtn.addEventListener("click", () => chrome.runtime.openOptionsPage());
@@ -788,7 +922,7 @@ profileSelect.addEventListener("change", () => {
void persistSelections();
});
updatePostingCount();
updateSiteTextCount();
updatePromptCount(0);
renderOutput();
setAnalyzing(false);

View File

@@ -76,22 +76,77 @@ body {
}
.toc-links {
display: grid;
gap: 8px;
display: block;
}
.toc a {
.toc ul {
list-style: none;
padding: 0;
margin: 0;
display: grid;
gap: 2px;
}
.toc-sub {
padding-left: 12px !important;
margin-top: 2px !important;
display: none;
}
.toc-sub.expanded {
display: grid !important;
}
.toc-item {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 6px;
border-radius: 6px;
cursor: pointer;
font-size: 12px;
color: var(--ink);
text-decoration: none;
}
.toc-item:hover {
background: var(--card-bg);
}
.toc-item .toc-caret {
display: inline-block;
width: 10px;
text-align: center;
color: var(--muted);
font-size: 10px;
transition: transform 0.15s;
}
.toc-item.expanded .toc-caret {
transform: rotate(90deg);
}
.toc li a {
display: block;
padding: 4px 6px;
border-radius: 6px;
color: var(--ink);
text-decoration: none;
font-size: 12px;
padding: 4px 6px;
border-radius: 8px;
}
.toc a:hover {
.toc li a:hover {
background: var(--card-bg);
}
.toc-sub li a {
color: var(--muted);
}
.toc-sub li a:hover {
color: var(--ink);
}
.sidebar-errors {
margin-top: auto;
border-radius: 10px;
@@ -139,8 +194,9 @@ body {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 16px;
padding: 16px;
padding: 0;
box-shadow: var(--panel-shadow);
overflow: hidden;
}
.panel-summary {
@@ -150,40 +206,58 @@ body {
align-items: baseline;
justify-content: flex-start;
gap: 12px;
padding: 12px 16px;
margin: 0;
}
.panel-summary::-webkit-details-marker {
display: none;
/* Restore native marker but keep list-style: none from above? No, remove list-style: none to show marker. */
/* Wait, display: flex might hide marker in some browsers. */
/* Usually marker is ::marker pseudo-element on summary. */
/* To show native marker, summary should be display: list-item or similar? */
/* Actually, standard is display: block (or list-item). Flex might kill it. */
/* If the user wants native glyphs, I should use list-item and maybe position the h2? */
/* Let's try reverting panel-summary to default display and styling h2 inline. */
.panel-summary {
cursor: pointer;
padding: 12px 16px;
margin: 0;
/* display: list-item; default */
}
.panel-caret {
display: inline-flex;
align-items: center;
width: 16px;
justify-content: center;
color: var(--muted);
font-weight: 700;
font-family: "Segoe UI Symbol", "Apple Symbols", system-ui, sans-serif;
}
.panel-caret .caret-open {
display: none;
}
.panel[open] .panel-caret .caret-open {
/* Need to align H2. */
.panel-summary h2 {
display: inline;
}
.panel[open] .panel-caret .caret-closed {
display: none;
.sub-panel .panel-summary {
padding: 10px 12px;
}
.sub-panel .panel-body {
padding: 0 12px 12px;
}
.panel-body {
margin-top: 12px;
margin-top: 0;
padding: 0 16px 16px;
}
.panel[open] .panel-summary {
margin-bottom: 6px;
margin-bottom: 0;
border-bottom: 1px solid var(--border);
}
.sub-panel {
box-shadow: none;
background: var(--card-bg);
border: 1px solid var(--border);
margin-bottom: 12px;
}
.sub-panel:last-child {
margin-bottom: 0;
}
.row {

View File

@@ -3,13 +3,13 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>WWCompanion Settings</title>
<title>SiteCompanion Settings</title>
<link rel="stylesheet" href="settings.css" />
</head>
<body>
<header class="title-block">
<div class="title">WWCompanion Settings</div>
<div class="subtitle">Configure prompts, resume, and API access</div>
<div class="title">SiteCompanion Settings</div>
<div class="subtitle">Configure workspaces, tasks, and API access</div>
</header>
<div class="page-bar">
<div id="status" class="status"></div>
@@ -18,134 +18,204 @@
<div class="settings-layout">
<nav class="toc" aria-label="Settings table of contents">
<div class="toc-heading">WWCompanion Settings</div>
<div class="toc-heading">SiteCompanion Settings</div>
<button id="saveBtnSidebar" class="accent" type="button">Save Settings</button>
<div id="statusSidebar" class="status"> </div>
<div class="toc-title">Sections</div>
<div class="toc-links">
<a href="#appearance-panel">Appearance</a>
<a href="#api-keys-panel">API Keys</a>
<a href="#api-panel">API</a>
<a href="#environment-panel">Environment</a>
<a href="#profiles-panel">My Profiles</a>
<a href="#tasks-panel">Task Presets</a>
<ul>
<li>
<div class="toc-item">
<span class="toc-caret"></span> <a href="#global-config-panel">Global Configuration</a>
</div>
<ul class="toc-sub hidden">
<li><a href="#appearance-panel">Appearance</a></li>
<li><a href="#api-keys-panel">API Keys</a></li>
<li><a href="#api-panel">API</a></li>
<li><a href="#environment-panel">Environments</a></li>
<li><a href="#profiles-panel">Profiles</a></li>
<li><a href="#tasks-panel">Tasks</a></li>
<li><a href="#presets-panel">Presets</a></li>
</ul>
</li>
<li>
<div class="toc-item">
<span class="toc-caret"></span> <a href="#workspaces-panel">Workspaces</a>
</div>
<ul class="toc-sub hidden" id="toc-workspaces-list"></ul>
</li>
<li>
<div class="toc-item">
<span class="toc-caret"></span> <a href="#sites-panel">Sites</a>
</div>
<ul class="toc-sub hidden" id="toc-sites-list"></ul>
</li>
</ul>
</div>
<div id="sidebarErrors" class="sidebar-errors hidden"></div>
</nav>
<main class="settings-main">
<details class="panel" id="appearance-panel">
<details class="panel" id="global-config-panel" open>
<summary class="panel-summary">
<span class="panel-caret" aria-hidden="true">
<span class="caret-closed"></span>
<span class="caret-open"></span>
</span>
<h2>Appearance</h2>
<h2>Global Configuration</h2>
</summary>
<div class="panel-body">
<div class="field">
<label for="themeSelect">Theme</label>
<select id="themeSelect">
<option value="system">System</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
</div>
</div>
</details>
<details class="panel" id="api-keys-panel">
<summary class="panel-summary">
<span class="panel-caret" aria-hidden="true">
<span class="caret-closed"></span>
<span class="caret-open"></span>
</span>
<h2>API KEYS</h2>
</summary>
<div class="panel-body">
<div class="row">
<div></div>
<button id="addApiKeyBtn" class="ghost" type="button">Add Key</button>
</div>
<div id="apiKeys" class="api-keys"></div>
</div>
</details>
<details class="panel" id="api-panel">
<summary class="panel-summary">
<span class="panel-caret" aria-hidden="true">
<span class="caret-closed"></span>
<span class="caret-open"></span>
</span>
<h2>API</h2>
</summary>
<div class="panel-body">
<div class="row">
<div></div>
<div class="row-actions">
<button id="addApiConfigBtn" class="ghost" type="button">Add Config</button>
<!-- Appearance -->
<details class="panel sub-panel" id="appearance-panel">
<summary class="panel-summary">
<h2>Appearance</h2>
</summary>
<div class="panel-body">
<div class="field">
<label for="themeSelect">Theme</label>
<select id="themeSelect">
<option value="system">System</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
</div>
<div class="field">
<label for="toolbarPositionSelect">Toolbar Position</label>
<select id="toolbarPositionSelect">
<option value="bottom-right">Bottom Right</option>
<option value="bottom-left">Bottom Left</option>
<option value="top-right">Top Right</option>
<option value="top-left">Top Left</option>
<option value="bottom-center">Bottom Center</option>
</select>
</div>
</div>
</div>
<div id="apiConfigs" class="api-configs"></div>
</details>
<!-- API Keys -->
<details class="panel sub-panel" id="api-keys-panel">
<summary class="panel-summary">
<h2>API KEYS</h2>
</summary>
<div class="panel-body">
<div class="row">
<div></div>
<button id="addApiKeyBtn" class="ghost" type="button">Add Key</button>
</div>
<div id="apiKeys" class="api-keys"></div>
</div>
</details>
<!-- API -->
<details class="panel sub-panel" id="api-panel">
<summary class="panel-summary">
<h2>API</h2>
</summary>
<div class="panel-body">
<div class="row">
<div></div>
<div class="row-actions">
<button id="addApiConfigBtn" class="ghost" type="button">Add Config</button>
</div>
</div>
<div id="apiConfigs" class="api-configs"></div>
</div>
</details>
<!-- Envs -->
<details class="panel sub-panel" id="environment-panel">
<summary class="panel-summary">
<div class="row-title">
<h2>ENVIRONMENTS</h2>
<span class="hint hint-accent">Baseline environments</span>
</div>
</summary>
<div class="panel-body">
<div class="row">
<div></div>
<button id="addEnvConfigBtn" class="ghost" type="button">Add Env</button>
</div>
<div id="envConfigs" class="env-configs"></div>
</div>
</details>
<!-- Profiles -->
<details class="panel sub-panel" id="profiles-panel">
<summary class="panel-summary">
<div class="row-title">
<h2>PROFILES</h2>
<span class="hint hint-accent">Baseline user contexts</span>
</div>
</summary>
<div class="panel-body">
<div class="row">
<div></div>
<button id="addProfileBtn" class="ghost" type="button">Add Profile</button>
</div>
<div id="profiles" class="profiles"></div>
</div>
</details>
<!-- Tasks -->
<details class="panel sub-panel" id="tasks-panel">
<summary class="panel-summary">
<div class="row-title">
<h2>TASKS</h2>
<span class="hint hint-accent">Baseline execution units</span>
</div>
</summary>
<div class="panel-body">
<div class="row">
<div></div>
<button id="addTaskBtn" class="ghost" type="button">Add Task</button>
</div>
<div id="tasks" class="tasks"></div>
</div>
</details>
<!-- Presets -->
<details class="panel sub-panel" id="presets-panel">
<summary class="panel-summary">
<div class="row-title">
<h2>PRESETS</h2>
<span class="hint hint-accent">Toolbar shortcuts</span>
</div>
</summary>
<div class="panel-body">
<div class="row">
<div></div>
<button id="addPresetBtn" class="ghost" type="button">Add Preset</button>
</div>
<div id="presets" class="presets"></div>
</div>
</details>
</div>
</details>
<details class="panel" id="environment-panel">
<details class="panel" id="workspaces-panel">
<summary class="panel-summary">
<span class="panel-caret" aria-hidden="true">
<span class="caret-closed"></span>
<span class="caret-open"></span>
</span>
<div class="row-title">
<h2>Environment</h2>
<span class="hint hint-accent">API configuration and system prompt go here</span>
<h2>Workspaces</h2>
<span class="hint hint-accent">Namespace for sites and resources</span>
</div>
</summary>
<div class="panel-body">
<div class="row">
<div></div>
<button id="addEnvConfigBtn" class="ghost" type="button">Add Config</button>
<button id="addWorkspaceBtn" class="ghost" type="button">Add Workspace</button>
</div>
<div id="envConfigs" class="env-configs"></div>
<div id="workspaces" class="workspaces"></div>
</div>
</details>
<details class="panel" id="profiles-panel">
<details class="panel" id="sites-panel">
<summary class="panel-summary">
<span class="panel-caret" aria-hidden="true">
<span class="caret-closed"></span>
<span class="caret-open"></span>
</span>
<div class="row-title">
<h2>My Profiles</h2>
<span class="hint hint-accent">Text to your resumes or generic profiles goes here</span>
<h2>Sites</h2>
<span class="hint hint-accent">Configure known sites</span>
</div>
</summary>
<div class="panel-body">
<div class="row">
<div></div>
<button id="addProfileBtn" class="ghost" type="button">Add Profile</button>
<button id="addSiteBtn" class="ghost" type="button">Add Site</button>
</div>
<div id="profiles" class="profiles"></div>
</div>
</details>
<details class="panel" id="tasks-panel">
<summary class="panel-summary">
<span class="panel-caret" aria-hidden="true">
<span class="caret-closed"></span>
<span class="caret-open"></span>
</span>
<div class="row-title">
<h2>Task Presets</h2>
<span class="hint hint-accent">Top task is the default</span>
</div>
</summary>
<div class="panel-body">
<div class="row">
<div></div>
<button id="addTaskBtn" class="ghost" type="button">Add Task</button>
</div>
<div id="tasks" class="tasks"></div>
<div id="sites" class="sites"></div>
</div>
</details>
</main>

File diff suppressed because it is too large Load Diff