Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import { projectActions } from './features/project/projectSlice';
import { statusActions } from './features/status/statusSlice';
import { useApp } from './hooks/useApp';
import { useGlobalKeyboardShortcuts } from './hooks/useGlobalKeyboardShortcuts';
import { useNativeNotifications } from './hooks/useNativeNotifications';
import { usePushToTalk } from './hooks/usePushToTalk';
import { useTranslation } from './hooks/useTranslation';
import { runCommandById } from './services/commands/commandBuilder';
Expand Down Expand Up @@ -481,6 +482,9 @@ const App: FC<AppProps> = ({ isNewUser }) => {
// QNBS-v3: Push-to-Talk voice activation when configured
usePushToTalk();

// QNBS-v3 (T3): bootstrap native notification permission when the setting is on. No-op on the web.
useNativeNotifications();

const executeCommand = useCallback(
(id: string) =>
runCommandById(id, {
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
<img src="https://img.shields.io/badge/Version-v1.25.0-6366F1" alt="v1.25.0">
<img src="https://img.shields.io/badge/Storage-IndexedDB_v8-F59E0B" alt="IndexedDB v8">
<img src="https://img.shields.io/badge/PWA-v3.0-5BB974?logo=pwa" alt="PWA v3.0">
<img src="https://img.shields.io/badge/i18n-19_locales-2861_keys-0EA5E9" alt="i18n 19 locales — 2861 keys">
<img src="https://img.shields.io/badge/i18n-19_locales-2869_keys-0EA5E9" alt="i18n 19 locales — 2869 keys">
<img src="https://img.shields.io/badge/Tests-6477%2B_%2F_532_files-22C55E" alt="6477+ tests / 532 files">
<img src="https://img.shields.io/codecov/c/github/qnbs/WorldScript-Studio?logo=codecov&label=Coverage" alt="Codecov Coverage">
<img src="https://img.shields.io/badge/License-MIT-22C55E" alt="License MIT">
Expand Down Expand Up @@ -396,7 +396,7 @@ Infrastructure-level features that keep the app fast and extensible as projects

### 🌐 Full Multi-Language Support

Shipped UI locales with **2861 i18n keys** across all 19 languages — zero hardcoded user-facing strings:
Shipped UI locales with **2869 i18n keys** across all 19 languages — zero hardcoded user-facing strings:

- 🇩🇪 **German** (Deutsch)
- 🇬🇧 **English**
Expand Down Expand Up @@ -505,7 +505,7 @@ The Settings → AI panel shows a live GPU status badge with adapter details and
| **PDF Export** | jsPDF | Client-side, configurable PDF document generation |
| **Document Export** | docx + jszip | Word-compatible `.docx` generation (lazy-loaded) |
| **PWA** | Service Worker + Web App Manifest v3 | Offline support, installability, Workbox chunking |
| **i18n** | Custom React Context (`I18nContext.tsx`) | 2861 keys × 19 locales (de/en/es/fr/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta); EN fallback; `localStorage` persistence |
| **i18n** | Custom React Context (`I18nContext.tsx`) | 2869 keys × 19 locales (de/en/es/fr/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta); EN fallback; `localStorage` persistence |
| **Testing** | Vitest 4.x (6477+ tests / 532 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) |
| **Code Quality** | Biome (lint + format) + TypeScript 7 (tsgo) strict | `--error-on-warnings` in CI; zero `any` policy |
| **Visualization** | Force-directed graph | Interactive character relationship network |
Expand Down Expand Up @@ -707,7 +707,7 @@ The main pipeline is [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Opt
**Current test metrics (2026-07-30, CI-reported):**
- **6477+ unit tests** across **532 test files** — all passing
- Coverage thresholds: lines ≥ 74 · branches ≥ 60 · functions ≥ 67 · statements ≥ 72 — enforced in CI (see Codecov badge for live metrics)
- i18n: **2861 keys × 19 locales** (en/de/fr/es/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu Beta)
- i18n: **2869 keys × 19 locales** (en/de/fr/es/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu Beta)

**CI-cloud-first workflow (recommended):** On constrained hardware run **`pnpm run lint && pnpm run i18n:check && pnpm run typecheck`** locally, then push and let CI handle coverage, E2E, Lighthouse, and Stryker. Authoritative numbers come from CI artifacts (Codecov, JUnit). After CI goes green, update the README badges and `AUDIT.md` quality-gate line from the reported metrics. See **[`docs/CI.md`](docs/CI.md) § Cloud CI-first vs local development** for the full post-merge doc-update checklist.

Expand Down
39 changes: 39 additions & 0 deletions app/listenerMiddleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { TypedStartListening } from '@reduxjs/toolkit';
import { createListenerMiddleware, isRejected } from '@reduxjs/toolkit';
import { analyticsActions } from '../features/analytics/analyticsSlice';
// QNBS-v3: canonical selector — replaces a local type that misapplied the persisted-state shape to the live store
import { proForgeActions } from '../features/proForge/proForgeSlice';
import { selectProjectData } from '../features/project/projectSelectors';
import type { ProjectData } from '../features/project/projectSlice';
import { statusActions } from '../features/status/statusSlice';
Expand Down Expand Up @@ -39,6 +40,7 @@ export { isAnalyticsPersistenceAllowed };
// 6. Cross-Project — search index updated on save (always-on; promoted from enableCrossProjectSearch flag)
// 7. WorkerBus v2 — init/shutdown pools on enableWorkerBusV2 flag change (Phase 2)
// 8. Rust Compute — invalidate Rust availability cache on enableRustCompute toggle (Phase 2)
// 9. Desktop Notify — native OS notification on ProForge stageCompleted (Phase 2 / T3)
//
// All AI inference side effects (local/cloud) are intentionally NOT in this middleware —
// they belong in service-layer thunks (aiProviderService, localAiFacade) to keep the
Expand Down Expand Up @@ -538,6 +540,43 @@ listenerMiddleware.startListening({
},
});

// QNBS-v3 (T3): Desktop Notifications — ProForge stage ready for review.
// `stageCompleted` is the one long-running "AI generation finished in the background" signal in
// this app (pipeline stages can run for minutes), so the user may have tabbed away. No-op unless
// running in Tauri with the opt-in `desktop.desktopNotifications` setting on (checked inside
// `sendDesktopNotification` + here). Title/body are resolved via the middleware-safe static i18n
// accessor (services/i18n/staticTranslate.ts) — listener middleware runs outside React, so it can't
// call useTranslation() directly; the stage name reuses the same `proforge.stageName.<stage>` keys
// ProForge's own UI resolves (components/proForge/PipelineReviewPanel.tsx).
Comment thread
qnbs marked this conversation as resolved.
Outdated
listenerMiddleware.startListening({
actionCreator: proForgeActions.stageCompleted,
effect: async (action, listenerApi) => {
const state = listenerApi.getState() as RootState;
if (!state.settings.desktop?.desktopNotifications) return;
Comment thread
qnbs marked this conversation as resolved.
Outdated
try {
const [{ sendDesktopNotification }, { getStaticTranslation, getCurrentLanguage }] =
await Promise.all([
import('../services/desktop/desktopNotifications'),
import('../services/i18n/staticTranslate'),
]);
const lang = getCurrentLanguage();
const stageLabel = await getStaticTranslation(
`proforge.stageName.${action.payload.stage}`,
lang,
);
const [title, body] = await Promise.all([
getStaticTranslation('desktop.notify.proforgeStageReadyTitle', lang),
getStaticTranslation('desktop.notify.proforgeStageReadyBody', lang, {
stage: stageLabel,
}),
]);
await sendDesktopNotification(title, body);
} catch (err) {
logger.warn('ProForge desktop notification failed', err);
}
},
});

/**
* QNBS-v3: Phase 2 — Cold-start WorkerBus v2 init. Mirrors initAdaptiveAiOnStartup:
* listeners only fire on transitions, so if the flag was already true in persisted state
Expand Down
9 changes: 9 additions & 0 deletions components/settings/DataSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,15 @@ export const DataSection: FC = () => {
description: t('settings.data.libraryExport.successBody'),
}),
);
if (settings.desktop?.desktopNotifications) {
void import('../../services/desktop/desktopNotifications').then(
({ sendDesktopNotification }) =>
sendDesktopNotification(
t('settings.data.libraryExport.successTitle'),
t('settings.data.libraryExport.successBody'),
),
);
Comment thread
qnbs marked this conversation as resolved.
Outdated
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}
Comment thread
qnbs marked this conversation as resolved.
setLibraryModalOpen(false);
setLibraryPassphrase('');
} catch {
Expand Down
14 changes: 14 additions & 0 deletions components/settings/DesktopSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ export const DesktopSection: FC = () => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const minimizeToTray = useAppSelector((s) => s.settings.desktop?.minimizeToTray ?? false);
// QNBS-v3 (T3): opt-in native OS notifications for background-task completion.
const desktopNotifications = useAppSelector(
(s) => s.settings.desktop?.desktopNotifications ?? false,
);

if (!isTauriRuntime()) return null;

Expand All @@ -33,6 +37,16 @@ export const DesktopSection: FC = () => {
dispatch(settingsActions.setDesktopSettings({ minimizeToTray: checked }))
}
/>
{/* QNBS-v3 (T3): permission is requested lazily by useNativeNotifications() the next time
this flips true — no permission prompt just from opening Settings. */}
<ToggleSwitch
label={t('desktop.settings.desktopNotifications')}
hint={t('desktop.settings.desktopNotificationsHint')}
checked={desktopNotifications}
onChange={(checked) =>
dispatch(settingsActions.setDesktopSettings({ desktopNotifications: checked }))
}
/>
</CardContent>
</Card>
);
Expand Down
17 changes: 17 additions & 0 deletions docs/DEEPSOURCE-REVIEW-LOOP.md
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,23 @@ GitHub App resumes auto-reviewing, run **both** loops: CodeAnt for narrative/AI
reset/resolve a new task sharing the same released port — fixed by filtering on `msg.taskId` in
`workerBus.ts`), and a missing `port.start()` call in `workerPool.ts::spawnWorker()` (an
`addEventListener`-based `MessagePort` never dispatches without it per the WHATWG spec).
- **2026-08-01** — Same pattern recurred on PR #306 (`feat/tauri-native-notifications`, T3 desktop
notifications): adding one new listener block to `app/listenerMiddleware.ts` (a ~800-line god-file)
triggered `DeepSource: JavaScript` to report **63 "introduced" issues** across the *entire* file —
JS-0067 (top-level function declarations, the repo's idiomatic module style), JS-R1005 (cyclomatic
complexity on pre-existing listener effects untouched by this PR, e.g. `addDebouncedListener`'s
internal effect and three older auto-save/DuckDB listeners), and JS-C1002 (short param names `c`/`p`
in pre-existing predicates). None of the 63 findings land on the actual new code (the
`proForgeActions.stageCompleted` listener, ~line 551) — confirmed by diffing finding line ranges
against the PR's real diff. Re-confirmed via `gh api .../branches/main/protection`: only
`✅ CI Success` is a required status check; `DeepSource: JavaScript` stayed informational-only.
Per the 2026-08-01/#305 precedent, did **not** scatter `# skipcq` across pre-existing unrelated code
— logged here instead and proceeded once the real (required) CI gates were green. **Reinforces the
standing conclusion:** any PR that merely imports/touches this file will keep re-triggering this
same false-positive wave until a maintainer applies a dashboard rule-level ignore for JS-0067 (and
likely scopes JS-R1005/JS-C1002 similarly for this file, or the file is decomposed) — an agent
should not attempt that maintainer-only dashboard action nor a speculative mass-refactor of this
critical middleware file as a side effect of an unrelated feature PR.

---

Expand Down
1 change: 1 addition & 0 deletions features/settings/settingsDefaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { Settings } from '../../types';

export const defaultDesktopSettings: Settings['desktop'] = {
minimizeToTray: false,
desktopNotifications: false,
Comment thread
qnbs marked this conversation as resolved.
Comment thread
qnbs marked this conversation as resolved.
};

export const defaultVoiceSettings: Settings['voice'] = {
Expand Down
4 changes: 4 additions & 0 deletions features/settings/settingsSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,10 @@ const settingsSlice = createSlice({
if (!state.aiMode) state.aiMode = 'hybrid';
// QNBS-v3: backfill openRouter for settings persisted before OpenRouter integration
if (!state.openRouter) state.openRouter = { ...DEFAULT_OPENROUTER_SETTINGS };
// QNBS-v3 (T3): merge (not replace) desktop settings so an older imported/persisted envelope
// that predates a new DesktopSettings field (e.g. desktopNotifications) doesn't leave it
// undefined — mirrors the backfill already done in services/storage/idbProjectStore.ts.
state.desktop = { ...defaultDesktopSettings, ...state.desktop };
Comment thread
qnbs marked this conversation as resolved.
Outdated
},
setTheme(state, action: PayloadAction<Theme>) {
state.theme = action.payload;
Expand Down
33 changes: 26 additions & 7 deletions hooks/useExportView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useTransientUiStore } from '../app/transientUiStore';
import { selectAllCharacters, selectAllWorlds } from '../features/project/projectSelectors';
import { generateSynopsisThunk } from '../features/project/thunks/writingThunks';
import { statusActions } from '../features/status/statusSlice';
import { sendDesktopNotification } from '../services/desktop/desktopNotifications';
import { useTranslation } from './useTranslation';

type Format = 'md' | 'txt' | 'pdf' | 'docx' | 'epub' | 'norm-txt';
Expand Down Expand Up @@ -34,6 +35,10 @@ export const useExportView = () => {
const dispatch = useAppDispatch();
const projectState = useAppSelector((state) => state.project.present);
const project = projectState.data;
// QNBS-v3 (T3): gate the post-export native notification behind the opt-in desktop setting.
const desktopNotificationsEnabled = useAppSelector(
(state) => state.settings.desktop?.desktopNotifications ?? false,
Comment thread
qnbs marked this conversation as resolved.
);
const characters = useAppSelector(selectAllCharacters);
const worlds = useAppSelector(selectAllWorlds);

Expand Down Expand Up @@ -139,7 +144,7 @@ export const useExportView = () => {
compileSuffix,
]);

const downloadPdf = useCallback(async () => {
const downloadPdf = useCallback(async (): Promise<boolean> => {
setIsExportLoading(true);
try {
const jspdfModule = await import('jspdf');
Expand Down Expand Up @@ -205,6 +210,7 @@ export const useExportView = () => {
}

doc.save(`${project.title}.pdf`);
return true;
} catch (error) {
dispatch(
statusActions.addNotification({
Expand All @@ -213,12 +219,13 @@ export const useExportView = () => {
description: error instanceof Error ? error.message : String(error),
}),
);
return false;
} finally {
setIsExportLoading(false);
}
}, [pdfOptions, project, synopsis, t, aiEnhancements, contentToExport, dispatch]);

const downloadDocx = useCallback(async () => {
const downloadDocx = useCallback(async (): Promise<boolean> => {
setIsExportLoading(true);
try {
const { Document, Packer, Paragraph, TextRun, HeadingLevel } = await import('docx');
Expand Down Expand Up @@ -273,6 +280,7 @@ export const useExportView = () => {
a.download = `${project.title}.docx`;
a.click();
URL.revokeObjectURL(url);
return true;
} catch (error) {
dispatch(
statusActions.addNotification({
Expand All @@ -281,12 +289,13 @@ export const useExportView = () => {
description: error instanceof Error ? error.message : String(error),
}),
);
return false;
} finally {
setIsExportLoading(false);
}
}, [project, synopsis, aiEnhancements, contentToExport, t, dispatch]);

const downloadEpub = useCallback(async () => {
const downloadEpub = useCallback(async (): Promise<boolean> => {
setIsExportLoading(true);
try {
const { buildPandocMarkdownFromProject } = await import('../services/pandocExportMarkdown');
Expand All @@ -301,7 +310,7 @@ export const useExportView = () => {
a.download = `${project.title.replace(/[/\\?%*:|"<>]/g, '-')}.epub`;
a.click();
URL.revokeObjectURL(url);
return;
return true;
}
const { exportEpub } = await import('../services/epubApiService');
const chapters = contentToExport.manuscript
Expand All @@ -318,6 +327,7 @@ export const useExportView = () => {
...(aiEnhancements.synopsis && synopsis.trim() ? { synopsis } : {}),
...(project.compileProfile ? { compileProfile: project.compileProfile } : {}),
});
return true;
} catch (error) {
dispatch(
statusActions.addNotification({
Expand All @@ -326,6 +336,7 @@ export const useExportView = () => {
description: error instanceof Error ? error.message : String(error),
}),
);
return false;
} finally {
setIsExportLoading(false);
}
Expand All @@ -341,12 +352,13 @@ export const useExportView = () => {

const handleDownload = useCallback(async () => {
Comment thread
qnbs marked this conversation as resolved.
Comment thread
qnbs marked this conversation as resolved.
Comment thread
qnbs marked this conversation as resolved.
try {
let exportSucceeded = true;
if (format === 'pdf') {
await downloadPdf();
exportSucceeded = await downloadPdf();
} else if (format === 'docx') {
await downloadDocx();
exportSucceeded = await downloadDocx();
} else if (format === 'epub') {
await downloadEpub();
exportSucceeded = await downloadEpub();
} else if (format === 'norm-txt') {
const { buildNormManuscriptExport } = await import('../services/normPageExport');
const textOutput = buildNormManuscriptExport(
Expand Down Expand Up @@ -376,6 +388,12 @@ export const useExportView = () => {
a.click();
URL.revokeObjectURL(url);
}
if (exportSucceeded && desktopNotificationsEnabled) {
void sendDesktopNotification(
t('export.notify.completeTitle'),
t('export.notify.completeBody'),
);
}
} catch (error) {
dispatch(
statusActions.addNotification({
Expand All @@ -393,6 +411,7 @@ export const useExportView = () => {
downloadPdf,
downloadDocx,
downloadEpub,
desktopNotificationsEnabled,
dispatch,
t,
]);
Expand Down
19 changes: 19 additions & 0 deletions hooks/useNativeNotifications.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* QNBS-v3 (T3): Bootstraps native OS notification permission when the user turns the
* `desktop.desktopNotifications` setting on. No-op on the web; never prompts unless the setting is
* on, so opening Settings alone never triggers an OS permission dialog.
*/

import { useEffect } from 'react';
import { useAppSelector } from '../app/hooks';
import { requestNotificationPermission } from '../services/desktop/desktopNotifications';
import { isTauriRuntime } from '../services/tauriRuntime';

export function useNativeNotifications(): void {
const enabled = useAppSelector((s) => s.settings.desktop?.desktopNotifications ?? false);

useEffect(() => {
if (!isTauriRuntime() || !enabled) return;
void requestNotificationPermission();
}, [enabled]);
}
Comment thread
qnbs marked this conversation as resolved.
Loading
Loading