diff --git a/src/chrome/src/ui/sidepanel.html b/src/chrome/src/ui/sidepanel.html
index 7dc90fd37..8ce0823ab 100644
--- a/src/chrome/src/ui/sidepanel.html
+++ b/src/chrome/src/ui/sidepanel.html
@@ -329,6 +329,7 @@
Start a new c
+
diff --git a/src/chrome/src/ui/sidepanel.js b/src/chrome/src/ui/sidepanel.js
index d2ed34012..b431a606b 100644
--- a/src/chrome/src/ui/sidepanel.js
+++ b/src/chrome/src/ui/sidepanel.js
@@ -558,6 +558,7 @@ const modeToggleHighlight = (() => {
return el;
})();
const actWarning = document.getElementById('act-warning');
+const actWarningDismiss = document.getElementById('act-warning-dismiss');
const inputArea = document.getElementById('input-area');
const slashCommandMenuEl = document.getElementById('slash-command-menu');
const queuedMessagesEl = document.getElementById('queued-messages');
@@ -1253,9 +1254,12 @@ chrome.storage.onChanged.addListener((changes) => {
// prompted per consequential action, so the standing banner is redundant —
// only surface it in Act mode when the gate is disabled.
const PERMISSION_GATE_KEY = 'askBeforeConsequentialActions';
+const ACT_WARNING_DISMISSED_KEY = 'actWarningDismissed';
const PERMISSION_EDUCATION_KEY = 'permissionPromptEducation';
const PERMISSION_EDUCATION_THRESHOLD = 2;
let askBeforeConsequential = true; // gate ON by default
+let actWarningDismissed = false;
+let actWarningPreferenceLoaded = false;
let permissionEducationState = { promptCount: 0, hintShown: false };
function normalizePermissionEducationState(value) {
@@ -1365,8 +1369,10 @@ async function maybeShowPermissionEducationHint(card) {
scrollToBottom();
}
-chrome.storage.local.get(PERMISSION_GATE_KEY).then((stored) => {
+chrome.storage.local.get([PERMISSION_GATE_KEY, ACT_WARNING_DISMISSED_KEY]).then((stored) => {
if (stored && stored[PERMISSION_GATE_KEY] === false) askBeforeConsequential = false;
+ actWarningDismissed = stored?.[ACT_WARNING_DISMISSED_KEY] === true;
+ actWarningPreferenceLoaded = true;
updateActWarning();
updateInputPlaceholder();
}).catch(() => {});
@@ -1382,14 +1388,28 @@ chrome.storage.onChanged.addListener((changes) => {
updateActWarning();
updateInputPlaceholder();
}
+ if (changes[ACT_WARNING_DISMISSED_KEY]) {
+ actWarningDismissed = changes[ACT_WARNING_DISMISSED_KEY].newValue === true;
+ actWarningPreferenceLoaded = true;
+ updateActWarning();
+ }
});
function updateActWarning() {
if (!actWarning) return;
- const show = agentMode !== 'ask' && !askBeforeConsequential;
+ const show = actWarningPreferenceLoaded
+ && !actWarningDismissed
+ && agentMode !== 'ask'
+ && !askBeforeConsequential;
actWarning.classList.toggle('hidden', !show);
}
+actWarningDismiss?.addEventListener('click', () => {
+ actWarningDismissed = true;
+ updateActWarning();
+ void chrome.storage.local.set({ [ACT_WARNING_DISMISSED_KEY]: true }).catch(() => {});
+});
+
/**
* Play a short chime when the agent finishes a task. Lazy-creates the Audio
* element the first time and reuses it after that — sidepanel.html is an
diff --git a/src/chrome/styles/sidepanel.css b/src/chrome/styles/sidepanel.css
index 698aec3e5..c49448919 100644
--- a/src/chrome/styles/sidepanel.css
+++ b/src/chrome/styles/sidepanel.css
@@ -1219,12 +1219,11 @@ body {
.chat-navigation {
position: absolute;
- left: 50%;
+ inset-inline-end: 12px;
bottom: 12px;
z-index: 7;
display: inline-block;
max-width: calc(100% - 36px);
- transform: translateX(-50%);
transition: transform 0.15s, opacity 0.4s ease;
}
@@ -1256,7 +1255,7 @@ body {
.chat-navigation:hover,
.chat-navigation:focus-within {
- transform: translate(-50%, -1px);
+ transform: translateY(-1px);
opacity: 1;
transition: transform 0.15s, opacity 0.12s ease;
}
@@ -2081,6 +2080,37 @@ body {
flex-shrink: 0;
}
+#act-warning-dismiss {
+ display: grid;
+ place-items: center;
+ width: 30px;
+ height: 30px;
+ margin-block: -4px;
+ margin-inline-start: auto;
+ padding: 0;
+ border: 0;
+ border-radius: 6px;
+ background: transparent;
+ color: currentColor;
+ cursor: pointer;
+ font: inherit;
+ font-size: 22px;
+ font-weight: 400;
+ line-height: 1;
+ opacity: 0.72;
+}
+
+#act-warning-dismiss:hover {
+ background: color-mix(in srgb, var(--warning) 12%, transparent);
+ opacity: 1;
+}
+
+#act-warning-dismiss:focus-visible {
+ outline: 3px solid color-mix(in srgb, var(--warning) 34%, transparent);
+ outline-offset: 1px;
+ opacity: 1;
+}
+
.selection-scope-banner {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
diff --git a/src/firefox/src/ui/sidepanel.html b/src/firefox/src/ui/sidepanel.html
index d7e296dac..9e1164970 100644
--- a/src/firefox/src/ui/sidepanel.html
+++ b/src/firefox/src/ui/sidepanel.html
@@ -293,6 +293,7 @@
Start a new c
+
diff --git a/src/firefox/src/ui/sidepanel.js b/src/firefox/src/ui/sidepanel.js
index 2e42e1410..e3050b56e 100644
--- a/src/firefox/src/ui/sidepanel.js
+++ b/src/firefox/src/ui/sidepanel.js
@@ -433,6 +433,7 @@ const modeToggleHighlight = (() => {
return el;
})();
const actWarning = document.getElementById('act-warning');
+const actWarningDismiss = document.getElementById('act-warning-dismiss');
const inputArea = document.getElementById('input-area');
const slashCommandMenuEl = document.getElementById('slash-command-menu');
const queuedMessagesEl = document.getElementById('queued-messages');
@@ -1347,9 +1348,12 @@ function isSuccessfulAskCompletion(mode, response) {
// prompted per consequential action, so the standing banner is redundant —
// only surface it in Act mode when the gate is disabled.
const PERMISSION_GATE_KEY = 'askBeforeConsequentialActions';
+const ACT_WARNING_DISMISSED_KEY = 'actWarningDismissed';
const PERMISSION_EDUCATION_KEY = 'permissionPromptEducation';
const PERMISSION_EDUCATION_THRESHOLD = 2;
let askBeforeConsequential = true; // gate ON by default
+let actWarningDismissed = false;
+let actWarningPreferenceLoaded = false;
let permissionEducationState = { promptCount: 0, hintShown: false };
function normalizePermissionEducationState(value) {
@@ -1459,8 +1463,10 @@ async function maybeShowPermissionEducationHint(card) {
scrollToBottom();
}
-browser.storage.local.get(PERMISSION_GATE_KEY).then((stored) => {
+browser.storage.local.get([PERMISSION_GATE_KEY, ACT_WARNING_DISMISSED_KEY]).then((stored) => {
if (stored && stored[PERMISSION_GATE_KEY] === false) askBeforeConsequential = false;
+ actWarningDismissed = stored?.[ACT_WARNING_DISMISSED_KEY] === true;
+ actWarningPreferenceLoaded = true;
updateActWarning();
updateInputPlaceholder();
}).catch(() => {});
@@ -1476,14 +1482,28 @@ browser.storage.onChanged.addListener((changes) => {
updateActWarning();
updateInputPlaceholder();
}
+ if (changes[ACT_WARNING_DISMISSED_KEY]) {
+ actWarningDismissed = changes[ACT_WARNING_DISMISSED_KEY].newValue === true;
+ actWarningPreferenceLoaded = true;
+ updateActWarning();
+ }
});
function updateActWarning() {
if (!actWarning) return;
- const show = agentMode !== 'ask' && !askBeforeConsequential;
+ const show = actWarningPreferenceLoaded
+ && !actWarningDismissed
+ && agentMode !== 'ask'
+ && !askBeforeConsequential;
actWarning.classList.toggle('hidden', !show);
}
+actWarningDismiss?.addEventListener('click', () => {
+ actWarningDismissed = true;
+ updateActWarning();
+ void browser.storage.local.set({ [ACT_WARNING_DISMISSED_KEY]: true }).catch(() => {});
+});
+
// Per-tab chat history (stores innerHTML of messages container).
// Also mirrored to browser.storage.session keyed `tabChat:` so the
// conversation survives the sidebar being closed and reopened.
diff --git a/src/firefox/styles/sidepanel.css b/src/firefox/styles/sidepanel.css
index 6c0e4c57e..dbb1e058a 100644
--- a/src/firefox/styles/sidepanel.css
+++ b/src/firefox/styles/sidepanel.css
@@ -1097,12 +1097,11 @@ body {
.chat-navigation {
position: absolute;
- left: 50%;
+ inset-inline-end: 12px;
bottom: 12px;
z-index: 7;
display: inline-block;
max-width: calc(100% - 36px);
- transform: translateX(-50%);
transition: transform 0.15s, opacity 0.4s ease;
}
@@ -1134,7 +1133,7 @@ body {
.chat-navigation:hover,
.chat-navigation:focus-within {
- transform: translate(-50%, -1px);
+ transform: translateY(-1px);
opacity: 1;
transition: transform 0.15s, opacity 0.12s ease;
}
@@ -1959,6 +1958,37 @@ body {
flex-shrink: 0;
}
+#act-warning-dismiss {
+ display: grid;
+ place-items: center;
+ width: 30px;
+ height: 30px;
+ margin-block: -4px;
+ margin-inline-start: auto;
+ padding: 0;
+ border: 0;
+ border-radius: 6px;
+ background: transparent;
+ color: currentColor;
+ cursor: pointer;
+ font: inherit;
+ font-size: 22px;
+ font-weight: 400;
+ line-height: 1;
+ opacity: 0.72;
+}
+
+#act-warning-dismiss:hover {
+ background: color-mix(in srgb, var(--warning) 12%, transparent);
+ opacity: 1;
+}
+
+#act-warning-dismiss:focus-visible {
+ outline: 3px solid color-mix(in srgb, var(--warning) 34%, transparent);
+ outline-offset: 1px;
+ opacity: 1;
+}
+
.selection-scope-banner {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
diff --git a/test/run.js b/test/run.js
index a759a2bb2..520a9cedf 100644
--- a/test/run.js
+++ b/test/run.js
@@ -26466,9 +26466,10 @@ test('sidepanel long replies use reading-first turn navigation', () => {
);
assert.match(
css,
- /#chat-shell \{[\s\S]*?position: relative;[\s\S]*?min-height: 0;[\s\S]*?\.chat-navigation \{[\s\S]*?position: absolute;[\s\S]*?left: 50%;[\s\S]*?transform: translateX\(-50%\);/,
- `${label}: navigation pill should be shell-positioned and RTL-safe`,
+ /#chat-shell \{[\s\S]*?position: relative;[\s\S]*?min-height: 0;[\s\S]*?\.chat-navigation \{[\s\S]*?position: absolute;[\s\S]*?inset-inline-end: 12px;/,
+ `${label}: navigation pill should sit quietly at the shell's logical trailing edge`,
);
+ assert.doesNotMatch(css, /\.chat-navigation \{[^}]*left: 50%;/, `${label}: navigation pill should no longer be centered over the reply`);
assert.match(
css,
/\.message\.assistant\.chat-navigation-inset \.message-content \{[\s\S]*?padding-block-end: 60px;/,
@@ -26673,6 +26674,45 @@ test('sidepanel long replies use reading-first turn navigation', () => {
}
});
+test('Act-mode risk warning can be dismissed permanently', () => {
+ for (const [label, prefix, storage] of [
+ ['chrome', 'src/chrome', 'chrome'],
+ ['firefox', 'src/firefox', 'browser'],
+ ]) {
+ const panel = fs.readFileSync(path.join(ROOT, prefix, 'src/ui/sidepanel.js'), 'utf8');
+ const html = fs.readFileSync(path.join(ROOT, prefix, 'src/ui/sidepanel.html'), 'utf8');
+ const css = fs.readFileSync(path.join(ROOT, prefix, 'styles/sidepanel.css'), 'utf8');
+
+ assert.match(
+ html,
+ /id="act-warning"[\s\S]*?data-i18n="sp\.mode\.act\.warning"[\s\S]*?id="act-warning-dismiss"[\s\S]*?data-i18n-aria-label="sp\.review\.close"/,
+ `${label}: the warning should keep its copy on the left and expose an accessible close button`,
+ );
+ assert.match(
+ css,
+ /#act-warning-dismiss \{[\s\S]*?width: 30px;[\s\S]*?height: 30px;[\s\S]*?margin-inline-start: auto;[\s\S]*?font-size: 22px;/,
+ `${label}: the warning close button should be prominent and pinned to the logical right`,
+ );
+ assert.match(css, /#act-warning-dismiss:focus-visible \{[\s\S]*?outline:/, `${label}: the warning close button should have a keyboard focus treatment`);
+ assert.match(panel, /const ACT_WARNING_DISMISSED_KEY = 'actWarningDismissed';/, `${label}: permanent dismissal should have one storage key`);
+ assert.match(
+ panel,
+ new RegExp(`${storage}\\.storage\\.local\\.get\\(\\[PERMISSION_GATE_KEY, ACT_WARNING_DISMISSED_KEY\\]\\)[\\s\\S]*?actWarningDismissed = stored\\?\\.\\[ACT_WARNING_DISMISSED_KEY\\] === true;[\\s\\S]*?actWarningPreferenceLoaded = true;`),
+ `${label}: warning visibility should wait for and restore the saved dismissal`,
+ );
+ assert.match(
+ panel,
+ /function updateActWarning\(\) \{[\s\S]*?actWarningPreferenceLoaded[\s\S]*?!actWarningDismissed[\s\S]*?agentMode !== 'ask'[\s\S]*?!askBeforeConsequential/,
+ `${label}: a dismissed warning should stay hidden across mode changes`,
+ );
+ assert.match(
+ panel,
+ new RegExp(`actWarningDismiss\\?\\.addEventListener\\('click',[\\s\\S]*?actWarningDismissed = true;[\\s\\S]*?updateActWarning\\(\\);[\\s\\S]*?${storage}\\.storage\\.local\\.set\\(\\{ \\[ACT_WARNING_DISMISSED_KEY\\]: true \\}\\)`),
+ `${label}: closing the warning should hide it immediately and persist forever`,
+ );
+ }
+});
+
test('compact clarification results settle the pending pre-card tool step', () => {
for (const [label, panelRel] of [
['chrome', 'src/chrome/src/ui/sidepanel.js'],