Skip to content

Commit e81811f

Browse files
committed
Disable dashboard side-panel action while panel is already open
1 parent 2eccca9 commit e81811f

8 files changed

Lines changed: 340 additions & 30 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ For complete project history, architecture decisions, execution timeline, built/
1919
- Search
2020
- Theme toggle (shared with full dashboard, live sync across open views)
2121
- `Expand` to full dashboard tab
22+
- Full-dashboard `Open Side Panel` button auto-disables while panel is already open in that window
2223
- Click any entry to focus an existing tab or open it if already closed.
2324
- Local-only storage (IndexedDB), no cloud sync.
2425
- Retention defaults to 30 days.

background/service-worker.js

Lines changed: 87 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ const CLEANUP_ALARM = "retention-cleanup";
2323
const CLEANUP_INTERVAL_MINUTES = 360;
2424
const RUNTIME_STORAGE_KEY = "runtime_state_v2";
2525
const FOCUS_MEANINGFUL_THRESHOLD_SEC = 10;
26+
const PANEL_HEARTBEAT_TTL_MS = 10_000;
2627

2728
const state = {
2829
focusedWindowId: chrome.windows.WINDOW_ID_NONE,
@@ -33,7 +34,8 @@ const state = {
3334
theme: "dark",
3435
openPanelOnActionClick: null,
3536
lastActionClickResult: null,
36-
lastOpenSidePanelResult: null
37+
lastOpenSidePanelResult: null,
38+
panelHeartbeatByWindow: {}
3739
};
3840

3941
const sessionEngine = createSessionEngine({
@@ -55,6 +57,64 @@ function broadcastTheme(theme) {
5557
});
5658
}
5759

60+
function broadcastSidePanelState(windowId, isOpen) {
61+
chrome.runtime.sendMessage({ type: "side-panel-state-changed", windowId, isOpen }).catch(() => {
62+
// Ignore when no extension page is listening.
63+
});
64+
}
65+
66+
function prunePanelHeartbeats(now = Date.now()) {
67+
for (const [windowIdRaw, lastSeenAtRaw] of Object.entries(state.panelHeartbeatByWindow)) {
68+
const windowId = Number(windowIdRaw);
69+
const lastSeenAt = Number(lastSeenAtRaw);
70+
if (!Number.isFinite(windowId) || !Number.isFinite(lastSeenAt) || now - lastSeenAt > PANEL_HEARTBEAT_TTL_MS) {
71+
delete state.panelHeartbeatByWindow[windowIdRaw];
72+
}
73+
}
74+
}
75+
76+
function registerPanelHeartbeat(windowId, at = Date.now()) {
77+
if (typeof windowId !== "number") {
78+
return false;
79+
}
80+
81+
const key = String(windowId);
82+
const wasOpen = key in state.panelHeartbeatByWindow;
83+
state.panelHeartbeatByWindow[key] = at;
84+
if (!wasOpen) {
85+
broadcastSidePanelState(windowId, true);
86+
}
87+
return true;
88+
}
89+
90+
function markPanelClosed(windowId) {
91+
if (typeof windowId !== "number") {
92+
return false;
93+
}
94+
95+
const key = String(windowId);
96+
const wasOpen = key in state.panelHeartbeatByWindow;
97+
delete state.panelHeartbeatByWindow[key];
98+
if (wasOpen) {
99+
broadcastSidePanelState(windowId, false);
100+
}
101+
return wasOpen;
102+
}
103+
104+
function isPanelOpenForWindow(windowId, now = Date.now()) {
105+
prunePanelHeartbeats(now);
106+
if (typeof windowId !== "number") {
107+
return false;
108+
}
109+
110+
const lastSeenAt = Number(state.panelHeartbeatByWindow[String(windowId)]);
111+
if (!Number.isFinite(lastSeenAt)) {
112+
return false;
113+
}
114+
115+
return now - lastSeenAt <= PANEL_HEARTBEAT_TTL_MS;
116+
}
117+
58118
function getSenderWindowId(sender) {
59119
return typeof sender?.tab?.windowId === "number" ? sender.tab.windowId : null;
60120
}
@@ -331,6 +391,7 @@ async function runRetentionCleanup() {
331391
const cutoffTimestampMs = Date.now() - retentionDays * 24 * 60 * 60 * 1000;
332392
await pruneSessionsOlderThan(cutoffTimestampMs);
333393
await pruneTabActivitiesOlderThan(cutoffTimestampMs);
394+
prunePanelHeartbeats();
334395

335396
const snapshots = await listTabSnapshots();
336397
await Promise.all(
@@ -627,6 +688,10 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
627688
return { opened, mode, senderWindowId };
628689
})()
629690
.then((result) => {
691+
if (result.opened && typeof result.senderWindowId === "number") {
692+
registerPanelHeartbeat(result.senderWindowId);
693+
}
694+
630695
state.lastOpenSidePanelResult = {
631696
ok: result.opened,
632697
opened: result.opened,
@@ -645,6 +710,20 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
645710
return true;
646711
}
647712

713+
if (message?.type === "panel-heartbeat") {
714+
const windowId = typeof message?.windowId === "number" ? message.windowId : getSenderWindowId(_sender);
715+
const ok = registerPanelHeartbeat(windowId);
716+
sendResponse({ ok, windowId });
717+
return false;
718+
}
719+
720+
if (message?.type === "panel-closed") {
721+
const windowId = typeof message?.windowId === "number" ? message.windowId : getSenderWindowId(_sender);
722+
const closed = markPanelClosed(windowId);
723+
sendResponse({ ok: true, windowId, closed });
724+
return false;
725+
}
726+
648727
if (message?.type === "debug-trigger-action-click") {
649728
executeActionClick({
650729
getFocusedWindowId: async () => {
@@ -676,6 +755,10 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
676755
}
677756

678757
if (message?.type === "get-runtime-status") {
758+
const senderWindowId = getSenderWindowId(_sender);
759+
const requestedWindowId = typeof message?.windowId === "number" ? message.windowId : senderWindowId;
760+
const sidePanelOpenForWindow = isPanelOpenForWindow(requestedWindowId);
761+
679762
const runtimeState = sessionEngine.readState();
680763
sendResponse({
681764
ok: true,
@@ -688,7 +771,9 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
688771
sidePanelApiAvailable: Boolean(chrome.sidePanel?.open),
689772
openPanelOnActionClick: state.openPanelOnActionClick,
690773
lastActionClickResult: state.lastActionClickResult,
691-
lastOpenSidePanelResult: state.lastOpenSidePanelResult
774+
lastOpenSidePanelResult: state.lastOpenSidePanelResult,
775+
sidePanelOpenForWindow,
776+
sidePanelWindowId: requestedWindowId
692777
});
693778
return false;
694779
}

project-history.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,14 @@ Chronological execution log:
200200
- e2e runtime broadcast tests for dashboard and settings
201201
- smoke assertions for two-way live sync (`dashboard -> panel` and `panel -> dashboard/settings`)
202202

203+
35. Added side-panel-open state guard for full dashboard action:
204+
- introduced panel heartbeat + close signaling (`panel-heartbeat`, `panel-closed`) from side panel UI
205+
- service worker now tracks window-scoped side-panel-open state with heartbeat TTL
206+
- full dashboard `Open Side Panel` button now disables while panel is open in that window and re-enables after close/expiry
207+
- expanded tests:
208+
- e2e button disable/re-enable coverage in dashboard spec
209+
- smoke assertions for disabled-while-open and enabled-after-close behavior
210+
203211
## 4. What Were The Decisions That We Took?
204212

205213
### Product/Architecture Decisions
@@ -315,6 +323,7 @@ Not in MVP (intentionally out of scope):
315323
- `npm run test:smoke:extension`: passing
316324
- `npm run test:flows:extension`: passing (`18/18` transition sequences)
317325
- Cross-surface live theme sync check: passing (`dashboard->panel`, `panel->dashboard/settings`)
326+
- Side-panel-open guard check: passing (`disabled while open`, `re-enabled after close`)
318327
- Long-duration headed validation: passing (`runId=20260226-192909`, `allTabsCount=6`, `neverFocused=4`, `retentionDays=30`, `theme=dark`)
319328
- Action-click config check: passing (`sidePanelApiAvailable=true`, `openPanelOnActionClick=true`)
320329

scripts/extension-flow-matrix-test.mjs

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -81,14 +81,25 @@ async function transitionOpenDashboard({ page, context }) {
8181
}
8282

8383
async function transitionOpenSidePanel({ page }) {
84-
await page.click("#open-side-panel");
85-
await page.waitForTimeout(250);
84+
const button = page.locator("#open-side-panel");
85+
const isDisabled = await button.isDisabled();
86+
if (!isDisabled) {
87+
await page.click("#open-side-panel");
88+
await page.waitForTimeout(250);
89+
}
90+
8691
const runtime = await runtimeStatus(page);
8792
const result = runtime?.lastOpenSidePanelResult;
93+
94+
if (isDisabled) {
95+
invariant(runtime?.sidePanelOpenForWindow === true, "open-side-panel disabled without an open side panel", {
96+
runtime
97+
});
98+
return;
99+
}
100+
88101
invariant(result?.ok === true, "open-side-panel did not report success", { runtime });
89-
invariant(["sender_window", "all_windows"].includes(result?.mode), "unexpected open-side-panel mode", {
90-
mode: result?.mode
91-
});
102+
invariant(["sender_window", "all_windows"].includes(result?.mode), "unexpected open-side-panel mode", { mode: result?.mode });
92103
}
93104

94105
async function transitionToggleTheme({ page }) {

scripts/extension-smoke-test.mjs

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,12 @@ async function run() {
4848
const panelThemeInitial = await panelPage.getAttribute("body", "data-theme");
4949
const initialPageCount = context.pages().length;
5050

51-
await dashboardPage.click("#open-side-panel");
52-
await dashboardPage.waitForTimeout(250);
51+
const dashboardSidePanelButton = dashboardPage.locator("#open-side-panel");
52+
const canRequestDashboardSidePanelOpen = await dashboardSidePanelButton.isEnabled();
53+
if (canRequestDashboardSidePanelOpen) {
54+
await dashboardPage.click("#open-side-panel");
55+
await dashboardPage.waitForTimeout(250);
56+
}
5357
const runtimeAfterDashboardSidePanel = await dashboardPage.evaluate(async () =>
5458
chrome.runtime.sendMessage({ type: "get-runtime-status" })
5559
);
@@ -83,6 +87,13 @@ async function run() {
8387
const panelThemeAfterPanelToggle = await panelPage.getAttribute("body", "data-theme");
8488
await dashboardPage.waitForFunction(() => document.body?.dataset?.theme === "dark", null, { timeout: 5_000 });
8589
const dashboardThemeAfterPanelToggle = await dashboardPage.getAttribute("body", "data-theme");
90+
const sidePanelButtonDisabledWhenPanelOpen = await dashboardPage.locator("#open-side-panel").isDisabled();
91+
92+
await panelPage.close();
93+
await dashboardPage.waitForFunction(() => document.querySelector("#open-side-panel")?.disabled === false, null, {
94+
timeout: 15_000
95+
});
96+
const sidePanelButtonEnabledAfterPanelClose = !(await dashboardPage.locator("#open-side-panel").isDisabled());
8697

8798
await dashboardPage.click("#open-settings");
8899
await dashboardPage.waitForURL((url) => url.toString().endsWith("/ui/settings.html"), { timeout: 5_000 });
@@ -148,6 +159,8 @@ async function run() {
148159
panelThemeAfterDashboardToggle,
149160
panelThemeAfterPanelToggle,
150161
dashboardThemeAfterPanelToggle,
162+
sidePanelButtonDisabledWhenPanelOpen,
163+
sidePanelButtonEnabledAfterPanelClose,
151164
runtimeAfterDashboardSidePanel,
152165
runtimeAfterSettingsSidePanel,
153166
dashboardFaviconHref,
@@ -167,6 +180,20 @@ async function run() {
167180

168181
console.log(JSON.stringify(result, null, 2));
169182

183+
const dashboardSidePanelRuntimeValid =
184+
result.runtimeAfterDashboardSidePanel?.sidePanelOpenForWindow === true &&
185+
(
186+
!result.runtimeAfterDashboardSidePanel?.lastOpenSidePanelResult ||
187+
(
188+
result.runtimeAfterDashboardSidePanel?.lastOpenSidePanelResult?.ok === true &&
189+
["sender_window", "all_windows"].includes(result.runtimeAfterDashboardSidePanel?.lastOpenSidePanelResult?.mode)
190+
)
191+
);
192+
193+
const settingsSidePanelRuntimeValid =
194+
result.runtimeAfterSettingsSidePanel?.lastOpenSidePanelResult?.ok === true &&
195+
["sender_window", "all_windows"].includes(result.runtimeAfterSettingsSidePanel?.lastOpenSidePanelResult?.mode);
196+
170197
if (
171198
result.dashboardHeading !== "Chrome Activity Reader" ||
172199
!result.activityListPresent ||
@@ -179,10 +206,8 @@ async function run() {
179206
result.openPanelOnActionClick !== true ||
180207
!String(result.dashboardFaviconHref || "").includes("icon-v2-32.png") ||
181208
!String(result.settingsFaviconHref || "").includes("icon-v2-32.png") ||
182-
result.runtimeAfterDashboardSidePanel?.lastOpenSidePanelResult?.ok !== true ||
183-
!["sender_window", "all_windows"].includes(result.runtimeAfterDashboardSidePanel?.lastOpenSidePanelResult?.mode) ||
184-
result.runtimeAfterSettingsSidePanel?.lastOpenSidePanelResult?.ok !== true ||
185-
!["sender_window", "all_windows"].includes(result.runtimeAfterSettingsSidePanel?.lastOpenSidePanelResult?.mode) ||
209+
dashboardSidePanelRuntimeValid !== true ||
210+
settingsSidePanelRuntimeValid !== true ||
186211
result.settingsOpenedInCurrentTab !== true ||
187212
result.dashboardOpenedInCurrentTab !== true ||
188213
result.pageCountUnchangedOnSettingsRoundTrip !== true ||
@@ -192,6 +217,8 @@ async function run() {
192217
result.panelThemeAfterDashboardToggle !== "light" ||
193218
result.panelThemeAfterPanelToggle !== "dark" ||
194219
result.dashboardThemeAfterPanelToggle !== "dark" ||
220+
result.sidePanelButtonDisabledWhenPanelOpen !== true ||
221+
result.sidePanelButtonEnabledAfterPanelClose !== true ||
195222
result.settingsTheme !== "dark" ||
196223
result.settingsThemeValue !== "dark" ||
197224
Number(result.themeSelectContrast || 0) < 4.5 ||

tests/e2e/dashboard.spec.js

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,13 @@ test.beforeEach(async ({ page }) => {
7171
await page.addInitScript(() => {
7272
window.__queryResult = [];
7373
window.__calls = [];
74-
window.__runtimeMessageListener = null;
74+
window.__runtimeMessageListeners = [];
75+
window.__runtimeStatus = {
76+
ok: true,
77+
meaningfulThresholdSec: 10,
78+
theme: "dark",
79+
sidePanelOpenForWindow: false
80+
};
7581

7682
window.chrome = {
7783
tabs: {
@@ -86,6 +92,7 @@ test.beforeEach(async ({ page }) => {
8692
}
8793
},
8894
windows: {
95+
getCurrent: async () => ({ id: 1 }),
8996
update: async (windowId, options) => {
9097
window.__calls.push({ api: "windows.update", windowId, options });
9198
return {};
@@ -98,17 +105,22 @@ test.beforeEach(async ({ page }) => {
98105
},
99106
onMessage: {
100107
addListener: (listener) => {
101-
window.__runtimeMessageListener = listener;
108+
window.__runtimeMessageListeners.push(listener);
102109
}
103110
},
104111
sendMessage: async (message) => {
105112
window.__calls.push({ api: "runtime.sendMessage", message });
106113
if (message?.type === "get-runtime-status") {
107-
return { ok: true, meaningfulThresholdSec: 10, theme: "dark" };
114+
return { ...window.__runtimeStatus };
108115
}
109116
if (message?.type === "set-theme") {
117+
window.__runtimeStatus.theme = message.theme;
110118
return { ok: true, theme: message.theme };
111119
}
120+
if (message?.type === "open-side-panel") {
121+
window.__runtimeStatus.sidePanelOpenForWindow = true;
122+
return { ok: true, mode: "sender_window" };
123+
}
112124
return { ok: true };
113125
},
114126
getURL: (path) => `chrome-extension://test/${path}`
@@ -298,10 +310,56 @@ test("dashboard applies broadcast theme updates from runtime", async ({ page })
298310
await expect(page.locator("body")).toHaveAttribute("data-theme", "dark");
299311

300312
await page.evaluate(() => {
301-
if (typeof window.__runtimeMessageListener === "function") {
302-
window.__runtimeMessageListener({ type: "theme-changed", theme: "light" }, {}, () => {});
313+
if (Array.isArray(window.__runtimeMessageListeners)) {
314+
for (const listener of window.__runtimeMessageListeners) {
315+
listener({ type: "theme-changed", theme: "light" }, {}, () => {});
316+
}
303317
}
304318
});
305319

306320
await expect(page.locator("body")).toHaveAttribute("data-theme", "light");
307321
});
322+
323+
test("open side panel button is disabled when side panel is already open for this window", async ({ page }) => {
324+
await page.goto("/ui/dashboard.html");
325+
326+
await page.evaluate(() => {
327+
window.__runtimeStatus.sidePanelOpenForWindow = true;
328+
if (Array.isArray(window.__runtimeMessageListeners)) {
329+
for (const listener of window.__runtimeMessageListeners) {
330+
listener({ type: "side-panel-state-changed", windowId: 1, isOpen: true }, {}, () => {});
331+
}
332+
}
333+
});
334+
335+
const openSidePanelButton = page.locator("#open-side-panel");
336+
await expect(openSidePanelButton).toBeDisabled();
337+
await expect(openSidePanelButton).toHaveText("Side Panel Open");
338+
});
339+
340+
test("open side panel button re-enables when runtime reports panel closed", async ({ page }) => {
341+
await page.goto("/ui/dashboard.html");
342+
343+
await page.evaluate(() => {
344+
window.__runtimeStatus.sidePanelOpenForWindow = true;
345+
if (Array.isArray(window.__runtimeMessageListeners)) {
346+
for (const listener of window.__runtimeMessageListeners) {
347+
listener({ type: "side-panel-state-changed", windowId: 1, isOpen: true }, {}, () => {});
348+
}
349+
}
350+
});
351+
352+
const openSidePanelButton = page.locator("#open-side-panel");
353+
await expect(openSidePanelButton).toBeDisabled();
354+
355+
await page.evaluate(() => {
356+
if (Array.isArray(window.__runtimeMessageListeners)) {
357+
for (const listener of window.__runtimeMessageListeners) {
358+
listener({ type: "side-panel-state-changed", windowId: 1, isOpen: false }, {}, () => {});
359+
}
360+
}
361+
});
362+
363+
await expect(openSidePanelButton).toBeEnabled();
364+
await expect(openSidePanelButton).toHaveText("Open Side Panel");
365+
});

0 commit comments

Comments
 (0)