diff --git a/electron/ipc-handlers/global-shortcuts.ts b/electron/ipc-handlers/global-shortcuts.ts index 7f1afe34d5..c4f1d418f8 100644 --- a/electron/ipc-handlers/global-shortcuts.ts +++ b/electron/ipc-handlers/global-shortcuts.ts @@ -2,6 +2,7 @@ import { globalShortcut, ipcMain } from 'electron'; import { IPC } from '../shared-with-frontend/ipc-events.const'; import { KeyboardConfig } from '../../src/app/features/config/keyboard-config.model'; import { getWin, setWasMaximizedBeforeHide } from '../main-window'; +import { toggleTaskWidgetVisibility } from '../task-widget/task-widget'; import { showOrFocus } from '../various-shared'; import { ensureIndicator } from '../indicator'; import { getIsMinimizeToTray } from '../shared-state'; @@ -22,6 +23,7 @@ const registerShowAppShortCuts = (cfg: KeyboardConfig): void => { 'globalToggleTaskStart', 'globalAddNote', 'globalAddTask', + 'globalToggleTaskWidget', ]; if (cfg) { @@ -82,6 +84,10 @@ const registerShowAppShortCuts = (cfg: KeyboardConfig): void => { }; break; + case 'globalToggleTaskWidget': + actionFn = toggleTaskWidgetVisibility; + break; + default: actionFn = () => undefined; } diff --git a/electron/main-window.ts b/electron/main-window.ts index 1a2b95c053..42a09cb1ba 100644 --- a/electron/main-window.ts +++ b/electron/main-window.ts @@ -19,6 +19,7 @@ import { IS_MAC, IS_GNOME_DESKTOP } from './common.const'; import { destroyTaskWidget, getIsTaskWidgetAlwaysShow, + getIsTaskWidgetUserForcedVisible, hideTaskWidget, showTaskWidget, } from './task-widget/task-widget'; @@ -452,21 +453,27 @@ function initWinEventListeners(app: Electron.App): void { appCloseHandler(app); appMinimizeHandler(app); - // Handle restore and show events to hide task widget + // Handle restore and show events to hide task widget. `getIsTaskWidgetUserForcedVisible()` + // keeps the widget up when the user explicitly revealed it via the global shortcut. mainWin.on('restore', () => { - if (!getIsTaskWidgetAlwaysShow()) { + if (!getIsTaskWidgetAlwaysShow() && !getIsTaskWidgetUserForcedVisible()) { hideTaskWidget(); } }); mainWin.on('show', () => { - if (!getIsTaskWidgetAlwaysShow()) { + if (!getIsTaskWidgetAlwaysShow() && !getIsTaskWidgetUserForcedVisible()) { hideTaskWidget(); } }); mainWin.on('focus', () => { - if (mainWin.isVisible() && !mainWin.isMinimized() && !getIsTaskWidgetAlwaysShow()) { + if ( + mainWin.isVisible() && + !mainWin.isMinimized() && + !getIsTaskWidgetAlwaysShow() && + !getIsTaskWidgetUserForcedVisible() + ) { hideTaskWidget(); } }); diff --git a/electron/task-widget.test.cjs b/electron/task-widget.test.cjs new file mode 100644 index 0000000000..3f060b7c33 --- /dev/null +++ b/electron/task-widget.test.cjs @@ -0,0 +1,297 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const Module = require('node:module'); + +require('ts-node/register/transpile-only'); + +const originalModuleLoad = Module._load; +const taskWidgetModulePath = path.resolve(__dirname, 'task-widget/task-widget.ts'); + +let createdWindows = []; +let loadSimpleStoreAllImpl; + +const createDeferred = () => { + let resolve; + let reject; + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve; + reject = promiseReject; + }); + + return { promise, resolve, reject }; +}; + +class FakeWebContents { + on() {} + send() {} + focus() {} + isDestroyed() { + return false; + } + removeAllListeners() {} +} + +class FakeBrowserWindow { + constructor() { + this._visible = false; + this.showCount = 0; + this.showInactiveCount = 0; + this.hideCount = 0; + this._handlers = new Map(); + this.webContents = new FakeWebContents(); + createdWindows.push(this); + } + + static getAllWindows() { + return createdWindows.slice(); + } + + loadFile() {} + setVisibleOnAllWorkspaces() {} + setOpacity() {} + setClosable() {} + removeAllListeners() {} + destroy() {} + on(eventName, handler) { + this._handlers.set(eventName, handler); + } + emit(eventName) { + const handler = this._handlers.get(eventName); + if (handler) handler(); + } + getBounds() { + return { width: 300, height: 80, x: 0, y: 0 }; + } + isDestroyed() { + return false; + } + isVisible() { + return this._visible; + } + show() { + this._visible = true; + this.showCount += 1; + } + showInactive() { + this._visible = true; + this.showInactiveCount += 1; + } + hide() { + this._visible = false; + this.hideCount += 1; + } +} + +const installMocks = () => { + Module._load = function patchedLoad(request, parent, isMain) { + if (request === 'electron') { + return { + BrowserWindow: FakeBrowserWindow, + ipcMain: { on: () => {}, removeAllListeners: () => {} }, + screen: { + getPrimaryDisplay: () => ({ workAreaSize: { width: 1920, height: 1080 } }), + getDisplayMatching: () => ({ + bounds: { x: 0, y: 0, width: 1920, height: 1080 }, + }), + }, + }; + } + if (request === 'electron-log/main') { + return { info: () => {} }; + } + if (request.endsWith('simple-store')) { + return { + loadSimpleStoreAll: () => loadSimpleStoreAllImpl(), + saveSimpleStore: () => {}, + }; + } + if (request.endsWith('common.const')) { + return { IS_MAC: false }; + } + return originalModuleLoad.call(this, request, parent, isMain); + }; +}; + +const loadModule = () => { + delete require.cache[taskWidgetModulePath]; + return require(taskWidgetModulePath); +}; + +const flush = () => new Promise((resolve) => setImmediate(resolve)); + +test.beforeEach(() => { + createdWindows = []; + loadSimpleStoreAllImpl = async () => ({}); + installMocks(); +}); + +test.afterEach(() => { + Module._load = originalModuleLoad; +}); + +test('toggleTaskWidgetVisibility is a no-op while the task widget feature is disabled', () => { + const mod = loadModule(); + mod.toggleTaskWidgetVisibility(); + assert.equal(createdWindows.length, 0, 'no window should be created when disabled'); +}); + +test('toggleTaskWidgetVisibility shows the widget when it is enabled but hidden', async () => { + const mod = loadModule(); + mod.updateTaskWidgetEnabled(true); + await flush(); + + assert.equal(createdWindows.length, 1, 'enabling should create the widget window'); + const win = createdWindows[0]; + assert.equal(win.isVisible(), false, 'widget starts hidden'); + + mod.toggleTaskWidgetVisibility(); + assert.equal(win.isVisible(), true, 'toggle should show the hidden widget'); + assert.equal(win.showInactiveCount, 1); +}); + +test('toggleTaskWidgetVisibility hides the widget when it is enabled and visible', async () => { + const mod = loadModule(); + mod.updateTaskWidgetEnabled(true); + await flush(); + + const win = createdWindows[0]; + mod.showTaskWidget(); + assert.equal(win.isVisible(), true, 'widget should be visible before toggling'); + + mod.toggleTaskWidgetVisibility(); + assert.equal(win.isVisible(), false, 'toggle should hide the visible widget'); + assert.equal(win.hideCount, 1); +}); + +test('forcing the widget visible via the shortcut sets a sticky user-forced flag', async () => { + const mod = loadModule(); + mod.updateTaskWidgetEnabled(true); + await flush(); + + assert.equal(mod.getIsTaskWidgetUserForcedVisible(), false, 'flag starts cleared'); + + mod.toggleTaskWidgetVisibility(); + assert.equal( + mod.getIsTaskWidgetUserForcedVisible(), + true, + 'showing via the shortcut sets the sticky flag', + ); + + mod.toggleTaskWidgetVisibility(); + assert.equal( + mod.getIsTaskWidgetUserForcedVisible(), + false, + 'hiding via the shortcut clears the sticky flag', + ); +}); + +test('disabling the widget clears the sticky user-forced flag', async () => { + const mod = loadModule(); + mod.updateTaskWidgetEnabled(true); + await flush(); + + mod.toggleTaskWidgetVisibility(); + assert.equal(mod.getIsTaskWidgetUserForcedVisible(), true); + + mod.updateTaskWidgetEnabled(false); + assert.equal( + mod.getIsTaskWidgetUserForcedVisible(), + false, + 'disabling the feature resets the sticky flag', + ); +}); + +test('disabling clears the sticky flag even when the widget window is absent', () => { + const mod = loadModule(); + + // Enable but do not flush: createTaskWidgetWindow() is mid-flight, so + // taskWidgetWin is still null (the "absent window" / async re-create gap). + mod.updateTaskWidgetEnabled(true); + + // User hits the shortcut during that gap — the flag is set without a window. + mod.toggleTaskWidgetVisibility(); + assert.equal(createdWindows.length, 0, 'no window exists yet'); + assert.equal( + mod.getIsTaskWidgetUserForcedVisible(), + true, + 'shortcut sets the sticky flag even without a window', + ); + + // Disabling now must clear the flag even though destroyTaskWidget() is + // skipped (its guard requires an existing window), or it would leak into + // the next enable. + mod.updateTaskWidgetEnabled(false); + assert.equal( + mod.getIsTaskWidgetUserForcedVisible(), + false, + 'disabling clears the flag regardless of whether the window exists', + ); +}); + +test('disabling while async creation is pending prevents the widget window from being created', async () => { + const storeLoad = createDeferred(); + loadSimpleStoreAllImpl = () => storeLoad.promise; + const mod = loadModule(); + + mod.updateTaskWidgetEnabled(true); + mod.updateTaskWidgetEnabled(false); + + storeLoad.resolve({}); + await flush(); + + assert.equal( + createdWindows.length, + 0, + 'disabling before persisted bounds load resolves should cancel window creation', + ); +}); + +test('shortcut reveal while async creation is pending shows the widget after creation completes', async () => { + const storeLoad = createDeferred(); + loadSimpleStoreAllImpl = () => storeLoad.promise; + const mod = loadModule(); + + mod.updateTaskWidgetEnabled(true); + mod.toggleTaskWidgetVisibility(); + + storeLoad.resolve({}); + await flush(); + + assert.equal(createdWindows.length, 1, 'only the initial in-flight creation is reused'); + assert.equal( + createdWindows[0].isVisible(), + true, + 'pending shortcut reveal should show the window once it exists', + ); +}); + +test('shortcut reveal uses showInactive so the current app keeps focus', async () => { + const mod = loadModule(); + mod.updateTaskWidgetEnabled(true); + await flush(); + + const win = createdWindows[0]; + mod.toggleTaskWidgetVisibility(); + + assert.equal(win.showInactiveCount, 1); + assert.equal(win.showCount, 0); + assert.equal(win.isVisible(), true, 'widget should still become visible'); +}); + +test('the closed event clears the sticky flag so it does not outlive the window', async () => { + const mod = loadModule(); + mod.updateTaskWidgetEnabled(true); + await flush(); + + const win = createdWindows[0]; + mod.toggleTaskWidgetVisibility(); + assert.equal(mod.getIsTaskWidgetUserForcedVisible(), true); + + win.emit('closed'); + assert.equal( + mod.getIsTaskWidgetUserForcedVisible(), + false, + 'closing the window clears the sticky flag', + ); +}); diff --git a/electron/task-widget/task-widget.ts b/electron/task-widget/task-widget.ts index a07d9052db..6739ad867a 100644 --- a/electron/task-widget/task-widget.ts +++ b/electron/task-widget/task-widget.ts @@ -10,6 +10,14 @@ import { IS_MAC } from '../common.const'; let taskWidgetWin: BrowserWindow | null = null; let isTaskWidgetEnabled = false; let isAlwaysShow = false; +// Set when the user explicitly reveals the widget via the global shortcut +// (`globalToggleTaskWidget`) while the main window is visible. Like +// `isAlwaysShow`, it suppresses the automatic "hide the widget when the main +// window is shown/focused" behavior — but only until the user hides the widget +// again (toggles off) or opens the app from the widget. This gives the shortcut +// a sticky "user-forced visible" effect instead of being immediately undone by +// the next focus event. +let isUserForcedVisible = false; let currentTask: TaskCopy | null = null; let isPomodoroEnabled = false; let currentPomodoroSessionTime = 0; @@ -18,16 +26,28 @@ let currentFocusSessionTime = 0; let initTimeoutId: NodeJS.Timeout | null = null; let currentOpacity = 95; let listenersRegistered = false; -let isCreatingWindow = false; +let taskWidgetCreationPromise: Promise | null = null; +let taskWidgetCreationGeneration = 0; +let pendingShowAfterCreate = false; +let pendingShowAfterCreateInactive = false; const TASK_WIDGET_BOUNDS_KEY = 'taskWidgetBounds'; const LEGACY_BOUNDS_KEY = 'overlayBounds'; let boundsDebounceTimer: NodeJS.Timeout | null = null; +type ShowTaskWidgetOptions = Readonly<{ + inactive?: boolean; +}>; + export const updateTaskWidgetEnabled = (isEnabled: boolean): void => { isTaskWidgetEnabled = isEnabled; - if (isEnabled && !taskWidgetWin && !isCreatingWindow) { + if (!isEnabled) { + destroyTaskWidget(); + return; + } + + if (!taskWidgetWin && !taskWidgetCreationPromise) { initListeners(); createTaskWidgetWindow().then(() => { // Window creation is async; re-apply the cached opacity here because @@ -44,11 +64,16 @@ export const updateTaskWidgetEnabled = (isEnabled: boolean): void => { mainWindow.webContents.send(IPC.REQUEST_CURRENT_TASK_FOR_TASK_WIDGET); } }); - } else if (!isEnabled && taskWidgetWin) { - destroyTaskWidget(); } }; +const clearPendingTaskWidgetCreation = (): void => { + taskWidgetCreationGeneration += 1; + taskWidgetCreationPromise = null; + pendingShowAfterCreate = false; + pendingShowAfterCreateInactive = false; +}; + export const destroyTaskWidget = (): void => { // Clear any pending timeouts if (initTimeoutId) { @@ -64,7 +89,8 @@ export const destroyTaskWidget = (): void => { // Disable task widget to prevent close event prevention isTaskWidgetEnabled = false; - isCreatingWindow = false; + isUserForcedVisible = false; + clearPendingTaskWidgetCreation(); // Remove IPC listeners ipcMain.removeAllListeners('task-widget-show-main-window'); @@ -97,11 +123,34 @@ export const destroyTaskWidget = (): void => { } }; -const createTaskWidgetWindow = async (): Promise => { - if (taskWidgetWin || isCreatingWindow) { +const createTaskWidgetWindow = (): Promise => { + if (taskWidgetWin) { + return Promise.resolve(); + } + + if (taskWidgetCreationPromise) { + return taskWidgetCreationPromise; + } + + const creationGeneration = taskWidgetCreationGeneration; + const nextCreationPromise = createTaskWidgetWindowForGeneration( + creationGeneration, + ).finally(() => { + if (taskWidgetCreationPromise === nextCreationPromise) { + taskWidgetCreationPromise = null; + } + }); + + taskWidgetCreationPromise = nextCreationPromise; + return nextCreationPromise; +}; + +const createTaskWidgetWindowForGeneration = async ( + creationGeneration: number, +): Promise => { + if (taskWidgetWin) { return; } - isCreatingWindow = true; const primaryDisplay = screen.getPrimaryDisplay(); const { width: screenWidth } = primaryDisplay.workAreaSize; @@ -143,7 +192,14 @@ const createTaskWidgetWindow = async (): Promise => { // Use defaults (file may not exist on first run) } - isCreatingWindow = false; + if ( + taskWidgetWin || + !isTaskWidgetEnabled || + creationGeneration !== taskWidgetCreationGeneration + ) { + return; + } + // On macOS, transparent + frameless windows do not support native window // dragging or edge resizing (see Electron's BrowserWindow docs: "Transparent // windows are not resizable. Setting `resizable` to `true` may make a @@ -190,6 +246,10 @@ const createTaskWidgetWindow = async (): Promise => { taskWidgetWin.on('closed', () => { taskWidgetWin = null; + // Tie "user-forced visible" to the window's lifetime: once the window is + // gone the sticky flag has no widget to keep visible, so don't let it + // linger into a future re-create. + isUserForcedVisible = false; }); taskWidgetWin.on('ready-to-show', () => { @@ -232,9 +292,30 @@ const createTaskWidgetWindow = async (): Promise => { // Update initial state updateTaskWidgetContent(); + + updateTaskWidgetOpacity(currentOpacity); + + if (pendingShowAfterCreate) { + const showInactive = pendingShowAfterCreateInactive; + pendingShowAfterCreate = false; + pendingShowAfterCreateInactive = false; + showTaskWidgetWindow({ inactive: showInactive }); + } }; -export const showTaskWidget = (): void => { +const showTaskWidgetWindow = (options: ShowTaskWidgetOptions = {}): void => { + if (!taskWidgetWin || taskWidgetWin.isDestroyed()) { + return; + } + + if (options.inactive) { + taskWidgetWin.showInactive(); + } else { + taskWidgetWin.show(); + } +}; + +export const showTaskWidget = (options: ShowTaskWidgetOptions = {}): void => { if (!isTaskWidgetEnabled) { return; } @@ -242,12 +323,9 @@ export const showTaskWidget = (): void => { // Recreate task widget if it was accidentally closed if (!taskWidgetWin) { info('Task widget window was destroyed, recreating'); - createTaskWidgetWindow().then(() => { - if (taskWidgetWin && !taskWidgetWin.isDestroyed()) { - updateTaskWidgetOpacity(currentOpacity); - taskWidgetWin.show(); - } - }); + pendingShowAfterCreate = true; + pendingShowAfterCreateInactive = pendingShowAfterCreateInactive || !!options.inactive; + createTaskWidgetWindow(); return; } @@ -258,7 +336,7 @@ export const showTaskWidget = (): void => { // Only show if not already visible if (!taskWidgetWin.isVisible()) { info('Showing task widget'); - taskWidgetWin.show(); + showTaskWidgetWindow(options); } else { info('Task widget already visible'); } @@ -284,6 +362,27 @@ export const hideTaskWidget = (): void => { } }; +/** + * Toggles the task widget's visibility. Intended for the global shortcut + * (`globalToggleTaskWidget`): it only acts when the task widget feature is + * enabled in settings and never changes that persisted enabled/disabled + * preference — it just shows or hides the existing widget. + */ +export const toggleTaskWidgetVisibility = (): void => { + if (!isTaskWidgetEnabled) { + return; + } + + if (taskWidgetWin && !taskWidgetWin.isDestroyed() && taskWidgetWin.isVisible()) { + isUserForcedVisible = false; + hideTaskWidget(); + return; + } + + isUserForcedVisible = true; + showTaskWidget({ inactive: true }); +}; + const initListeners = (): void => { if (listenersRegistered) { return; @@ -299,6 +398,10 @@ const initListeners = (): void => { // event.preventDefault() on 'minimize' has no effect). mainWindow.restore(); mainWindow.show(); + // Opening the app from the widget is an explicit "I'm going to the app" + // gesture, so clear any sticky user-forced visibility and let the widget + // follow the normal companion behavior again. + isUserForcedVisible = false; if (!isAlwaysShow) { hideTaskWidget(); } @@ -374,6 +477,8 @@ export const updateTaskWidgetAlwaysShow = (alwaysShow: boolean): void => { export const getIsTaskWidgetAlwaysShow = (): boolean => isAlwaysShow; +export const getIsTaskWidgetUserForcedVisible = (): boolean => isUserForcedVisible; + export const updateTaskWidgetOpacity = (opacity: number): void => { currentOpacity = opacity; if (!taskWidgetWin || taskWidgetWin.isDestroyed()) { diff --git a/electron/various-shared.ts b/electron/various-shared.ts index 74eb635c0f..c699106ee7 100644 --- a/electron/various-shared.ts +++ b/electron/various-shared.ts @@ -1,7 +1,11 @@ import { app, BrowserWindow } from 'electron'; import { info } from 'electron-log/main'; import { getWin, getWasMaximizedBeforeHide } from './main-window'; -import { getIsTaskWidgetAlwaysShow, hideTaskWidget } from './task-widget/task-widget'; +import { + getIsTaskWidgetAlwaysShow, + getIsTaskWidgetUserForcedVisible, + hideTaskWidget, +} from './task-widget/task-widget'; import { setIsQuiting } from './shared-state'; // eslint-disable-next-line prefer-arrow/prefer-arrow-functions @@ -36,8 +40,9 @@ export function showOrFocus(passedWin: BrowserWindow): void { if (getWasMaximizedBeforeHide()) win.maximize(); } - // Hide task widget when main window is shown - if (!getIsTaskWidgetAlwaysShow()) { + // Hide task widget when main window is shown, unless the user explicitly + // pinned it visible via the global shortcut. + if (!getIsTaskWidgetAlwaysShow() && !getIsTaskWidgetUserForcedVisible()) { hideTaskWidget(); } diff --git a/src/app/features/config/default-global-config.const.ts b/src/app/features/config/default-global-config.const.ts index b0ec1bdd7b..f26e2356e2 100644 --- a/src/app/features/config/default-global-config.const.ts +++ b/src/app/features/config/default-global-config.const.ts @@ -131,6 +131,7 @@ export const DEFAULT_GLOBAL_CONFIG: GlobalConfigState = { globalToggleTaskStart: null, globalAddNote: null, globalAddTask: null, + globalToggleTaskWidget: null, addNewTask: 'Shift+A', addNewProject: 'Shift+P', addNewNote: 'Alt+N', diff --git a/src/app/features/config/form-cfgs/keyboard-form.const.ts b/src/app/features/config/form-cfgs/keyboard-form.const.ts index 2ced22526b..dcd9906e24 100644 --- a/src/app/features/config/form-cfgs/keyboard-form.const.ts +++ b/src/app/features/config/form-cfgs/keyboard-form.const.ts @@ -40,6 +40,7 @@ export const KEYBOARD_SETTINGS_FORM_CFG: ConfigFormSection = { kbField('globalToggleTaskStart', T.GCF.KEYBOARD.GLOBAL_TOGGLE_TASK_START), kbField('globalAddNote', T.GCF.KEYBOARD.GLOBAL_ADD_NOTE), kbField('globalAddTask', T.GCF.KEYBOARD.GLOBAL_ADD_TASK), + kbField('globalToggleTaskWidget', T.GCF.KEYBOARD.GLOBAL_TOGGLE_TASK_WIDGET), ] : []), // APP WIDE diff --git a/src/app/features/config/keyboard-config.model.ts b/src/app/features/config/keyboard-config.model.ts index cc9a445813..6a1d02c464 100644 --- a/src/app/features/config/keyboard-config.model.ts +++ b/src/app/features/config/keyboard-config.model.ts @@ -2,6 +2,7 @@ export type KeyboardConfig = Readonly<{ globalShowHide?: string | null; globalAddNote?: string | null; globalAddTask?: string | null; + globalToggleTaskWidget?: string | null; toggleBacklog?: string | null; goToFocusMode?: string | null; goToWorkView?: string | null; diff --git a/src/app/t.const.ts b/src/app/t.const.ts index e2a4b4322b..1524bc6863 100644 --- a/src/app/t.const.ts +++ b/src/app/t.const.ts @@ -2403,6 +2403,7 @@ const T = { GLOBAL_ADD_TASK: 'GCF.KEYBOARD.GLOBAL_ADD_TASK', GLOBAL_SHOW_HIDE: 'GCF.KEYBOARD.GLOBAL_SHOW_HIDE', GLOBAL_TOGGLE_TASK_START: 'GCF.KEYBOARD.GLOBAL_TOGGLE_TASK_START', + GLOBAL_TOGGLE_TASK_WIDGET: 'GCF.KEYBOARD.GLOBAL_TOGGLE_TASK_WIDGET', GO_TO_DAILY_AGENDA: 'GCF.KEYBOARD.GO_TO_DAILY_AGENDA', GO_TO_FOCUS_MODE: 'GCF.KEYBOARD.GO_TO_FOCUS_MODE', GO_TO_SCHEDULE: 'GCF.KEYBOARD.GO_TO_SCHEDULE', diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 56ee1bb495..ea6bba5d36 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -2346,6 +2346,7 @@ "GLOBAL_ADD_TASK": "Add new task", "GLOBAL_SHOW_HIDE": "Show/hide Super Productivity", "GLOBAL_TOGGLE_TASK_START": "Start/stop time tracking for last active task", + "GLOBAL_TOGGLE_TASK_WIDGET": "Show/hide task widget", "GO_TO_DAILY_AGENDA": "Go to agenda", "GO_TO_FOCUS_MODE": "Enter focus mode", "GO_TO_SCHEDULE": "Go to Today",