feat(desktop): screenshot popup close button, remembered mini timer position, custom and start/stop sounds

Merged
EV
15d ago
+3308-61

Summary

Four small UX changes to the desktop apps (Gauzy Desktop, Desktop Timer, Agent):

  1. #7643: the screenshot notification has a close (X) button that hides it at once.
  2. #7683: the mini timer (always-on widget) opens where it was last left, as long as that spot is still on a connected display.
  3. #6715: each computer can pick its own screenshot sound (WAV, MP3 or OGG, 10 MB at most) and go back to the default sound. The sound stays on that computer.
  4. #7141: optional start and stop sounds for the timer. They are off by default, so existing users notice nothing until they turn them on.

Nothing is removed. Every default keeps today's behaviour.

Decisions

  1. "Sound Enabled" is unchanged. It still covers the screenshot sound only. The start/stop sounds keep their own switch, and there is no master mute in this PR.
  2. The midnight rollover is silent. It stops the timer and starts it again for the new day. That is an internal split, not the user starting or stopping, so it plays no cue. If that restart fails and leaves the timer stopped, the stop cue plays.
  3. Mini timer positions are kept per size. The compact (60Ɨ110) and expanded (270Ɨ45) widgets differ in shape and the mode is a setting, so each keeps its own spot. A shared position would put the wide bar where the small widget fitted and clamp it.
  4. The timer sounds read only the sound settings. They use a small GET_SOUND_SETTINGS handler. The existing app_setting handler is untouched.

Why

  • #7643: until now the screenshot popup could only be waited out (it hides 3 s after the capture).
  • #7683: the mini timer always opened at the left edge of the primary display, so users moved it again on every launch.
  • #6715: the screenshot sound was fixed to the bundled file.
  • #7141: there was no audible confirmation that tracking started or stopped.

What changed

@gauzy/desktop-window

  • ScreenCaptureNotification:
    • New hideNow() hides the window immediately and drops the pending delayed hide.
    • hide() keeps its 3 s delay but restarts it when called again, so a notification shown in the meantime stays up its full time.
    • Each notification window listens on close_screen_capture_notification. It only accepts the message from its own page (event.sender check) and stops listening once it is destroyed.
    • acceptFirstMouse: true lets the first click reach the never-focused window on macOS.
  • New utils/window-position.ts (pure): restoreWindowPosition() puts a saved position back on the display it overlaps most, clamped so the window is whole on that display. It returns null when the spot is on no connected display (for example a monitor was unplugged).
  • New utils/window-position-memory.ts: WindowPositionMemory saves the position 500 ms after a drag rests, and again when the window is destroyed. A save that failed is tried again on close. It writes to the electron-store key windowPositions, kept apart from appSetting, which the settings page writes back whole.
  • AlwaysOn restores the position separately for the compact and expanded sizes. It falls back to today's default (x 16, vertically centred on the primary display) when nothing usable is saved.
  • Each widget now takes its size from AlwaysOn.SIZES for its own mode. The old mutable static size made a compact widget opened after an expanded one in the same process 270Ɨ45.

@gauzy/desktop-ui-lib

  • screen-capture: ghost close button (close-outline icon, translated aria-label/title) at the right of the title row.
  • New settings/screenshot-sound/: ScreenshotSoundSettingService asks the main process to choose or reset the sound, explains a refused file, and confirms a change. The file dialog texts come from the page, so they are in the user's language.
  • settings.component:
    • Screen Capture menu, Sound Notifications section: the current screenshot sound, Choose sound... and Use default.
    • Timer menu, new "Timer Sounds" heading: the switch "Play a short sound when the timer starts or stops".
      • It does not depend on screenshot capture being allowed.
      • It is hidden in the Agent, which has no timer.
  • New time-tracker/sounds/:
    • timerSoundCues(quiet$): a pure rxjs operator that turns the time-tracker store's ignition changes into start/stop cues. A run of this computer cues a start and its stop. Silent cases:
      • a failed start;
      • a silent restart to change the task or project, including the STARTED without a mode that the task change dialog sets afterwards;
      • another device's timer that this computer only displays;
      • whatever was already true when it started watching.
    • Quiet stretches: while quiet$ is true, changes are followed but not cued. When the stretch ends, only its net effect is cued.
    • timer-sound-tones.ts: two short synthesized notes per cue with the Web Audio API, so there is no audio file to ship or license. Start goes up (E5 to A5) and stop goes down (A5 to D5), with a fade in and out to avoid clicks. Apps can supply their own notes through the TIMER_SOUND_TONES token.
    • TimerSoundService:
      • Reads only the sound settings (DesktopSoundSettingsChannel.GET) on each cue, so switching the setting applies at once.
      • quietly(work) runs an internal split without cues.
      • Opens one AudioContext lazily, resumes it if suspended, and never throws.
    • SoundFilePlayer:
      • The play_sound listener now makes a new audio element when the file changes. A newly chosen sound is heard from the next screenshot onward without restarting, and an empty first message no longer throws.
      • File system paths become encoded file URLs (soundFileUrl()), so #, ?, % or a space in a folder name no longer cut the path.
  • time-tracker.component.ts: only call sites. These are:
    • the field;
    • one watch(...) subscription;
    • the play_sound listener delegating to SoundFilePlayer;
    • the midnight rollover wrapped in quietly().

@gauzy/desktop-lib

  • New screenshot-sound/:
    • Pure resolver: a stored path is only played while it is a supported file inside userData/sounds.
    • Service:
      • Imports the file: checks the extension, that it is a regular file and the size limit, then copies it to userData/sounds/screenshot-sound-<uuid>.<ext>.
      • Reset goes back to the bundled sound.
      • Deletes the previous copy, and the new copy when the setting could not be saved. It only ever deletes files in that folder.
    • IPC handlers SELECT_SCREENSHOT_SOUND / RESET_SCREENSHOT_SOUND.
  • New sound-settings/: GET_SOUND_SETTINGS answers with the sound settings only ({ timerStartStopSound }).
  • desktop-ipc.ts registers both modules' handlers in ipcMainHandler and removes them in removeAllHandlers().
  • desktop-screenshot.ts: both screenshot paths play through playScreenshotSound().

@gauzy/contracts

  • IDesktopScreenshotSound, IDesktopScreenshotSoundResult, DesktopScreenshotSoundChannel, DesktopScreenshotSoundError.
  • IDesktopSoundSettings, DesktopSoundSettingsChannel.

@gauzy/desktop-core

  • appSetting.customScreenshotSound and appSetting.timerStartStopSound.
  • The windowPositions store key and the IWindowPosition type.

Agent (apps/agent)

  • Registers the screenshot-sound handlers next to its other IPC handlers.
  • ScreenCaptureWindow.hideNow() closes the notification through the window manager's hideNotificationWindow(). That cancels the manager's own auto-hide timeout, so it cannot close the next notification early.
  • isWindowReadyToShow() stops waiting when the window was closed or replaced meanwhile, instead of polling a destroyed window forever.

i18n

  • English texts in packages/ui-core/i18n/assets/i18n/en.json (TIMER_TRACKER.SETTINGS.*). Other languages fall back to English until translated.

Flags / settings and defaults

SettingDefaultEffect of the default
appSetting.timerStartStopSoundfalseNo start/stop sounds, as today
appSetting.customScreenshotSoundnullThe bundled screenshot sound, as today
electron-store windowPositionsabsentMini timer at today's default position until the user moves it
Screenshot notification auto-hide3 s (ScreenCaptureNotification.AUTO_HIDE_DELAY_MS)Unchanged

Tests

Run from the repository root with the workspace Node (v26):

CommandResult
npx jest -c packages/desktop-window/jest.config.ts --rootDir packages/desktop-window6 suites, 67 tests passed
NODE_OPTIONS=--experimental-vm-modules npx jest -c packages/desktop-lib/jest.config.ts --rootDir packages/desktop-lib src/lib/sound-settings src/lib/screenshot-sound src/lib/desktop-screenshot.spec.ts6 suites, 46 tests passed
npx jest -c packages/desktop-ui-lib/jest.config.ts --rootDir packages/desktop-ui-lib src/lib/settings src/lib/screen-capture src/lib/time-tracker/sounds src/lib/time-tracker/time-tracker.component.spec.ts26 suites, 97 tests passed
npx jest -c apps/agent/jest.config.ts --rootDir apps/agent src/main/window/screen-capture-window.spec.ts src/main/window-manager.spec.ts2 suites, 8 tests passed
npx jest -c packages/desktop-core/jest.config.ts --rootDir packages/desktop-core1 suite, 2 tests passed
npx tsc --noEmit -p packages/<contracts, desktop-core, desktop-lib, desktop-window>/tsconfig.lib.json and the desktop-lib / desktop-window tsconfig.spec.jsonNo errors
npx tsc --noEmit -p packages/desktop-ui-lib/tsconfig.lib.json and tsconfig.spec.jsonNo errors in the changed files. The only lib errors are the existing Cannot find namespace 'google' ones in packages/ui-core/shared.

The Agent's changed window files also type-check against the package sources. The default agent tsconfig.electron.json resolves @gauzy/* to another checkout's builds here.

eslint on the new and changed TypeScript files reports no new errors, only no-explicit-any warnings in specs, which matches existing spec style.

Manual verification still needed

These were not verifiable here: they need a running Electron build on Windows, macOS and Linux.

  1. #7643
    • Take a screenshot and click the X: the popup hides at once.
    • On macOS, check that the first click works on the non-focused popup.
    • Take two screenshots within 3 s: the second popup stays its full 3 s.
    • Agent: click X, then take another screenshot at once: the new popup stays its full time.
  2. #7683
    • Move the mini timer, quit and relaunch: it opens where it was.
    • Expand it, move it, relaunch: the expanded position is remembered separately.
    • Unplug the monitor it was on: it opens at the default position.
  3. #6715
    • Choose an MP3, WAV or OGG and take a screenshot: the new sound plays without a restart. Check also with a # or a space in the user folder name.
    • Use default brings the bundled sound back.
    • A file over 10 MB or of another type is refused with a message.
    • With the sound muted, nothing plays, including in the in-app screenshot preview.
    • The Agent's settings show the chooser and its screenshots use the sound.
  4. #7141
    • The switch is in the Timer menu and off on a fresh profile.
    • Turn it on: start gives a rising cue and stop a falling one.
    • Changing the task while tracking is silent, and the later stop still plays.
    • Following another device's timer is silent.
    • The midnight rollover is silent.
    • Turning the switch off applies at once.

Risk and rollback

  • Low risk. The start/stop sounds and the custom sound are opt-in. The window position is only used when it is on a connected display.
  • Behaviour change, a bug fix: the in-app screenshot preview (showCapturedToRenderer) used to play the screenshot sound even with the sound muted. It now honours the mute, like the notification path already did.
  • With the start/stop sounds on, a run of this computer that is stopped from elsewhere (another device, quit or logout, an error) plays the stop cue, because this computer did stop tracking.
  • Rollback: revert the PR. The new store values (customScreenshotSound, timerStartStopSound, windowPositions) are ignored by older builds. A copied sound file stays in userData/sounds until removed.

Closes #7643
Closes #7683
Closes #6715
Closes #7141

šŸ¤– Generated with Claude Code