Compare commits

...
2 Commits
Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 359c79a440 feat(desktop): ask once what closing the window should do (cinny-desktop #5)
CI / Build & Quality Checks (push) Successful in 1m57s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 15s
CI / Trigger Desktop Build (push) Successful in 9s
CI / Playwright smoke (e2e) (push) Canceled after 12m7s
The first time the window is closed, a dialog asks "Keep Lotus Chat
running?" — Keep running in the tray (default, focused) / Quit when I
close the window — with an opt-in "Start Lotus Chat when I sign in"
checkbox in the same moment (per the approved design: one dialog, no
wizard). The choice is saved natively; Settings → General → "When I
close the window" changes it later (tray / quit / ask me).

The dialog is role="dialog" aria-modal, labelled and described, with
focus on the default button. Web-side flow verified with a stubbed
native side: event → dialog → checkbox + Quit sends autostart enable +
resolve_close_request("quit"); the Settings select saves "tray".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-24 12:51:18 -04:00
Lotus CIandClaude Opus 5.5 206a3e933a fix(a11y): thread chip says it opens the thread, and whether it's unread/muted (#179)
CI / Build & Quality Checks (push) Successful in 2m15s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 14s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
The "N replies · <time>" chip under a thread root was named only by its
visible text, so a screen reader never said it opens the thread, and the
unread dot / muted bell were visual-only. Its accessible name is now the
visible text first (WCAG 2.5.3) plus "view thread", "unread replies" and
"muted" as applicable, e.g. "1 reply · 12:16 PM, view thread".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-24 12:18:35 -04:00
4 changed files with 160 additions and 4 deletions
+114
View File
@@ -0,0 +1,114 @@
import React, { useCallback, useEffect, useState } from 'react';
import FocusTrap from 'focus-trap-react';
import {
Box,
Button,
Checkbox,
Dialog,
Header,
Overlay,
OverlayBackdrop,
OverlayCenter,
Text,
config,
} from 'folds';
import { invokeTauri, isTauri, tauriInvoke, useTauriEvent } from '../hooks/useTauri';
import { useModalStyle } from '../hooks/useModalStyle';
/**
* cinny-desktop #5: the first time the window is closed, ask whether Lotus Chat
* should keep running in the tray (calls, messages, notifications) or quit —
* and, in the same moment, whether it should start when you sign in. Asked
* once; changeable in Settings → General. The native side sends
* `lotus-close-requested` only while the choice is still "ask", and never
* during a call (a call always goes to the tray).
*/
export function CloseBehaviorPrompt() {
const [open, setOpen] = useState(false);
const [launchOnLogin, setLaunchOnLogin] = useState(false);
const modalStyle = useModalStyle(420);
// Tells the native side we're listening (see get_close_behavior).
useEffect(() => {
if (!isTauri()) return;
tauriInvoke()?.('get_close_behavior').catch(() => undefined);
}, []);
useTauriEvent('lotus-close-requested', () => setOpen(true));
const choose = useCallback(
(value: 'tray' | 'quit') => {
if (launchOnLogin) invokeTauri('plugin:autostart|enable');
setOpen(false);
invokeTauri('resolve_close_request', { value });
},
[launchOnLogin],
);
if (!open) return null;
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: '#close-behavior-keep',
// Dismissing without choosing keeps the window open; the next close
// asks again.
onDeactivate: () => setOpen(false),
clickOutsideDeactivates: true,
}}
>
<Dialog
variant="Surface"
role="dialog"
aria-modal="true"
aria-labelledby="close-behavior-title"
aria-describedby="close-behavior-body"
style={modalStyle}
>
<Header
style={{
padding: `0 ${config.space.S400}`,
borderBottomWidth: config.borderWidth.B300,
}}
variant="Surface"
size="500"
>
<Text as="h2" size="H4" id="close-behavior-title">
Keep Lotus Chat running?
</Text>
</Header>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Text priority="400" id="close-behavior-body">
Lotus Chat can keep running in the system tray when you close this window, so
messages, notifications and calls still reach you. Open it again from the tray icon.
</Text>
<Box as="label" alignItems="Center" gap="200" style={{ cursor: 'pointer' }}>
<Checkbox
size="300"
variant="Primary"
checked={launchOnLogin}
onClick={(e: React.MouseEvent<HTMLInputElement>) =>
setLaunchOnLogin(e.currentTarget.checked)
}
/>
<Text size="T300">Start Lotus Chat when I sign in to my computer</Text>
</Box>
<Box direction="Column" gap="200">
<Button id="close-behavior-keep" variant="Primary" onClick={() => choose('tray')}>
<Text size="B400">Keep running in the tray</Text>
</Button>
<Button variant="Secondary" fill="Soft" onClick={() => choose('quit')}>
<Text size="B400">Quit when I close the window</Text>
</Button>
</Box>
<Text size="T200" priority="300">
You can change this in Settings → General.
</Text>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
}
+14 -4
View File
@@ -19,6 +19,18 @@ export function ThreadSummary({ rootEvent, room, onOpen }: ThreadSummaryProps) {
const { count, latestTs } = summary;
const latestStr = latestTs !== undefined ? format(latestTs) : undefined;
const visibleText = `${count === 1 ? '1 reply' : `${count} replies`}${latestStr ? ` · ${latestStr}` : ''}`;
// #179: the chip was named only by its visible text, so a screen reader never
// heard that it opens the thread, nor about the unread dot / muted bell. Keep
// the visible text first (WCAG 2.5.3, label in name) and add the rest.
const ariaLabel = [
visibleText,
'view thread',
unread > 0 ? 'unread replies' : undefined,
mode === ThreadNotificationMode.Mute ? 'muted' : undefined,
]
.filter(Boolean)
.join(', ');
return (
<Box style={{ marginTop: config.space.S200 }}>
@@ -26,6 +38,7 @@ export function ThreadSummary({ rootEvent, room, onOpen }: ThreadSummaryProps) {
variant="SurfaceVariant"
radii="300"
className={MobileTouchTarget}
aria-label={ariaLabel}
before={<Icon size="50" src={Icons.Thread} />}
after={
unread > 0 ? <Badge variant="Success" fill="Solid" radii="Pill" size="200" /> : undefined
@@ -35,10 +48,7 @@ export function ThreadSummary({ rootEvent, room, onOpen }: ThreadSummaryProps) {
if (threadId) onOpen(threadId);
}}
>
<Text size="T200">
{count === 1 ? '1 reply' : `${count} replies`}
{latestStr ? ` · ${latestStr}` : ''}
</Text>
<Text size="T200">{visibleText}</Text>
{mode === ThreadNotificationMode.Mute && <Icon size="50" src={Icons.BellMute} />}
</Chip>
</Box>
@@ -164,11 +164,18 @@ function AutostartSetting() {
// [cinny-desktop #3] null until the native side answers (older builds don't
// have the command, so the switch just stays hidden there).
const [startMinimized, setStartMinimized] = useState<boolean | null>(null);
// [cinny-desktop #5] "ask" | "tray" | "quit"; null on builds without it.
const [closeBehavior, setCloseBehavior] = useState<string | null>(null);
useEffect(() => {
tauriInvoke()?.('plugin:autostart|is_enabled')
.then((value) => setEnabled(value === true))
.catch(() => undefined);
tauriInvoke()?.('get_close_behavior')
.then((value) => {
if (typeof value === 'string') setCloseBehavior(value);
})
.catch(() => undefined);
tauriInvoke()?.('get_start_minimized')
.then((value) => {
if (typeof value === 'boolean') setStartMinimized(value);
@@ -181,6 +188,11 @@ function AutostartSetting() {
setEnabled(value);
};
const handleCloseBehavior = (value: string) => {
invokeTauri('set_close_behavior', { value });
setCloseBehavior(value);
};
const handleStartMinimized = (value: boolean) => {
invokeTauri('set_start_minimized', { value });
setStartMinimized(value);
@@ -194,6 +206,24 @@ function AutostartSetting() {
description="Start Lotus Chat automatically when you sign in to your computer."
after={<Switch variant="Primary" value={enabled} onChange={handleChange} />}
/>
{closeBehavior !== null && (
<SettingTile
title="When I close the window"
description="Keep Lotus Chat running in the system tray (messages, notifications and calls keep arriving), or quit it. During a call, closing always keeps it running."
after={
<SettingsSelect
value={closeBehavior}
onChange={handleCloseBehavior}
aria-label="When I close the window"
options={[
{ value: 'tray', label: 'Keep running in the tray' },
{ value: 'quit', label: 'Quit Lotus Chat' },
{ value: 'ask', label: 'Ask me' },
]}
/>
}
/>
)}
{enabled && startMinimized !== null && (
<SettingTile
title="Start minimized"
@@ -70,6 +70,7 @@ import { getRoomRetentionMs, isExpired } from '../../utils/retention';
import { useTauriUpdateProgress, useTauriUpdater } from '../../hooks/useTauriUpdater';
import { isNetworkUpdateError } from '../../utils/updateErrors';
import { invokeTauri, isTauri as isTauriApp, useTauriEvent } from '../../hooks/useTauri';
import { CloseBehaviorPrompt } from '../../components/CloseBehaviorPrompt';
import { TauriDesktopFeatures } from '../../components/TauriDesktopFeatures';
import { KeyboardShortcutsDialog, useKeyboardShortcutsTrigger } from '../../features/shortcuts';
import { useRoomsListener } from '../../hooks/useRoomsListener';
@@ -1088,6 +1089,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
<RetentionSweeper />
<TauriUpdateFeature />
<TauriDesktopFeatures />
<CloseBehaviorPrompt />
<LotusDenoiseFeature />
<DeepLinkNavigator />
<KeyboardShortcutsFeature />