@nextclaw/ui 0.12.8 → 0.12.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/assets/ChannelsList-Ita2Zm1_.js +8 -0
  3. package/dist/assets/{DocBrowser-BMxf9CIK.js → DocBrowser-6ReNjvzF.js} +1 -1
  4. package/dist/assets/DocBrowser-BNwbPHf4.js +1 -0
  5. package/dist/assets/{DocBrowserContext-Ce28gRXt.js → DocBrowserContext-B6SpA7Qs.js} +1 -1
  6. package/dist/assets/{LogoBadge-o92MOA2L.js → LogoBadge-ByNLYg65.js} +1 -1
  7. package/dist/assets/MarketplacePage-CjX2MWww.js +1 -0
  8. package/dist/assets/{MarketplacePage-BySqkYDh.js → MarketplacePage-D0sDlYX4.js} +1 -1
  9. package/dist/assets/McpMarketplacePage-BGKJm1sJ.js +40 -0
  10. package/dist/assets/{ModelConfig-IrmzoslW.js → ModelConfig-BzZenCH-.js} +1 -1
  11. package/dist/assets/{ProviderScopedModelInput-CmTIzgI7.js → ProviderScopedModelInput-Da7khnBA.js} +1 -1
  12. package/dist/assets/{ProvidersList-8_Kalfwl.js → ProvidersList-BbVzRxjY.js} +1 -1
  13. package/dist/assets/RemoteAccessPage-BaDH_X1Q.js +1 -0
  14. package/dist/assets/RuntimeConfig-F_XKGgLm.js +1 -0
  15. package/dist/assets/{SearchConfig-DNBR-UbE.js → SearchConfig-BGkzXQP-.js} +1 -1
  16. package/dist/assets/{SecretsConfig-Ba1RPJaG.js → SecretsConfig-D281Rotl.js} +2 -2
  17. package/dist/assets/{SessionsConfig-Doqp5ghH.js → SessionsConfig-ChHQ7M5c.js} +2 -2
  18. package/dist/assets/{app-query-client-DniXoIN5.js → app-query-client-VnFElj4E.js} +1 -1
  19. package/dist/assets/{book-open-DocgeQtR.js → book-open-BdcxxoQu.js} +1 -1
  20. package/dist/assets/chat-page-Doe0yTtB.js +58 -0
  21. package/dist/assets/chat-session-display-cw78aiI_.js +1 -0
  22. package/dist/assets/{chunk-JZWAC4HX-BvKvh1R8.js → chunk-JZWAC4HX-DK5HPmIK.js} +1 -1
  23. package/dist/assets/{client-CVqPF5ie.js → client-_i4MU2bB.js} +1 -1
  24. package/dist/assets/{config-Bop2oB18.js → config-DtIQwrHF.js} +1 -1
  25. package/dist/assets/{createLucideIcon-DVv8taGY.js → createLucideIcon-BSeTgkZW.js} +1 -1
  26. package/dist/assets/desktop-update-config-Dpcf4BKG.js +1 -0
  27. package/dist/assets/{dist-Da5Gm_pO.js → dist-6TrrnPCR.js} +1 -1
  28. package/dist/assets/{dist-DmAlInRu.js → dist-ccBFUi-o.js} +1 -1
  29. package/dist/assets/download-BhDxnyvU.js +1 -0
  30. package/dist/assets/{external-link-DFjw3x1B.js → external-link-BgErLCNT.js} +1 -1
  31. package/dist/assets/{hash-DJtaCejM.js → hash-Bl7dr_UG.js} +1 -1
  32. package/dist/assets/i18n-eDHeDY0n.js +1 -0
  33. package/dist/assets/index-CF9xve0E.js +6 -0
  34. package/dist/assets/index-FgA52VBt.css +1 -0
  35. package/dist/assets/{infiniteQueryBehavior-DHSEQ3OH.js → infiniteQueryBehavior-ZDS92Qpp.js} +1 -1
  36. package/dist/assets/loader-circle-ACM1s51e.js +1 -0
  37. package/dist/assets/{logos-DEFUIR12.js → logos-x89HbrZ4.js} +1 -1
  38. package/dist/assets/{page-layout-Da3i3r6G.js → page-layout-vZnghcFy.js} +1 -1
  39. package/dist/assets/play-CFUwCA2E.js +1 -0
  40. package/dist/assets/plus-rYsv72JG.js +1 -0
  41. package/dist/assets/{popover-C_mWOFzI.js → popover-Bg1VoTZ6.js} +1 -1
  42. package/dist/assets/{refresh-ccw-D6HkNtfz.js → refresh-ccw-DT98i__E.js} +1 -1
  43. package/dist/assets/{refresh-cw-DRcvRrnc.js → refresh-cw-C47QSEwg.js} +1 -1
  44. package/dist/assets/{rotate-cw-BmDKfXtH.js → rotate-cw-JtFzpNn6.js} +1 -1
  45. package/dist/assets/{save-DHGmi2e9.js → save-3S6-H3Xw.js} +1 -1
  46. package/dist/assets/search-3kFR_zh9.js +1 -0
  47. package/dist/assets/{security-config-CbXfPZzr.js → security-config-BWaiARNk.js} +1 -1
  48. package/dist/assets/{select-Caud8QvU.js → select-DJ2MUjBB.js} +1 -1
  49. package/dist/assets/skeleton-ByQepn0M.js +1 -0
  50. package/dist/assets/{status-dot-DurKKSwA.js → status-dot-vbanNPFU.js} +1 -1
  51. package/dist/assets/{switch-0rmPBRKI.js → switch-BsLtHOH-.js} +1 -1
  52. package/dist/assets/{tabs-custom-5JLVL6v8.js → tabs-custom-D3HYMt6k.js} +1 -1
  53. package/dist/assets/{trash-2-C6caKPoz.js → trash-2-G48scll7.js} +1 -1
  54. package/dist/assets/{use-infinite-scroll-loader-dwnaa_qi.js → use-infinite-scroll-loader-DkNhD-42.js} +1 -1
  55. package/dist/assets/{useConfirmDialog-mMeWD_yo.js → useConfirmDialog-BkvTN-vd.js} +1 -1
  56. package/dist/assets/{useMutation-BmxxvCNf.js → useMutation-CBWjE2uj.js} +1 -1
  57. package/dist/assets/x-ByDbItbq.js +1 -0
  58. package/dist/index.html +95 -21
  59. package/dist/manifest.webmanifest +30 -0
  60. package/dist/offline.html +102 -0
  61. package/dist/pwa-192.png +0 -0
  62. package/dist/pwa-512.png +0 -0
  63. package/dist/sw.js +80 -0
  64. package/index.html +73 -1
  65. package/package.json +6 -6
  66. package/public/manifest.webmanifest +30 -0
  67. package/public/offline.html +102 -0
  68. package/public/pwa-192.png +0 -0
  69. package/public/pwa-512.png +0 -0
  70. package/public/sw.js +80 -0
  71. package/src/api/server-path.ts +27 -4
  72. package/src/api/types.ts +17 -10
  73. package/src/app.tsx +9 -0
  74. package/src/components/chat/ChatSidebar.test.tsx +43 -1
  75. package/src/components/chat/ChatSidebar.tsx +24 -0
  76. package/src/components/chat/adapters/chat-message.summary-truncation.test.ts +66 -0
  77. package/src/components/chat/adapters/file-operation/card.ts +9 -0
  78. package/src/components/chat/adapters/file-operation/diff.ts +14 -0
  79. package/src/components/chat/{ChatConversationPanel.test.tsx → chat-conversation-panel.test.tsx} +107 -206
  80. package/src/components/chat/chat-conversation-panel.tsx +412 -0
  81. package/src/components/chat/chat-page-shell.tsx +1 -1
  82. package/src/components/chat/chat-session-workspace-file-preview.test.tsx +91 -0
  83. package/src/components/chat/chat-session-workspace-file-preview.tsx +307 -0
  84. package/src/components/chat/chat-session-workspace-panel-nav.tsx +197 -0
  85. package/src/components/chat/chat-session-workspace-panel.tsx +318 -0
  86. package/src/components/chat/chat-sidebar-session-item.tsx +32 -2
  87. package/src/components/chat/containers/chat-message-list.container.test.tsx +49 -0
  88. package/src/components/chat/containers/chat-message-list.container.tsx +4 -0
  89. package/src/components/chat/managers/chat-session-list.manager.test.ts +12 -0
  90. package/src/components/chat/managers/chat-session-list.manager.ts +7 -0
  91. package/src/components/chat/ncp/ncp-chat-page.tsx +7 -7
  92. package/src/components/chat/ncp/ncp-chat-thread.manager.ts +179 -41
  93. package/src/components/chat/ncp/ncp-session-adapter.test.ts +35 -1
  94. package/src/components/chat/ncp/ncp-session-adapter.ts +17 -0
  95. package/src/components/chat/ncp/page/ncp-chat-derived-state.ts +54 -11
  96. package/src/components/chat/ncp/tests/ncp-chat-thread.manager.test.ts +189 -0
  97. package/src/components/chat/presenter/chat-presenter-context.tsx +13 -2
  98. package/src/components/chat/session-header/chat-session-header-actions.test.tsx +26 -0
  99. package/src/components/chat/session-header/chat-session-header-actions.tsx +19 -1
  100. package/src/components/chat/stores/chat-thread.store.ts +24 -0
  101. package/src/components/config/RuntimeConfig.tsx +141 -2
  102. package/src/components/layout/AppLayout.tsx +1 -1
  103. package/src/components/providers/ThemeProvider.tsx +5 -0
  104. package/src/hooks/server-path/use-server-path-read.ts +20 -0
  105. package/src/lib/chat-message.ts +14 -3
  106. package/src/lib/i18n.chat.ts +12 -1
  107. package/src/lib/i18n.pwa.ts +62 -0
  108. package/src/lib/i18n.ts +2 -2
  109. package/src/pwa/components/pwa-install-entry.test.tsx +110 -0
  110. package/src/pwa/components/pwa-install-entry.tsx +205 -0
  111. package/src/pwa/managers/pwa-install.manager.test.ts +160 -0
  112. package/src/pwa/managers/pwa-install.manager.ts +232 -0
  113. package/src/pwa/managers/pwa-runtime.manager.ts +196 -0
  114. package/src/pwa/managers/pwa-shell-theme.manager.test.ts +30 -0
  115. package/src/pwa/managers/pwa-shell-theme.manager.ts +46 -0
  116. package/src/pwa/pwa-install-banner.storage.ts +55 -0
  117. package/src/pwa/pwa.types.ts +22 -0
  118. package/src/pwa/register-pwa.ts +14 -0
  119. package/src/pwa/stores/pwa.store.ts +17 -0
  120. package/src/vite-env.d.ts +9 -0
  121. package/dist/assets/ChannelsList-KIQIxluX.js +0 -8
  122. package/dist/assets/DocBrowser-CyDgAtO9.js +0 -1
  123. package/dist/assets/MarketplacePage-C0olZaek.js +0 -1
  124. package/dist/assets/McpMarketplacePage-DqKaiXO9.js +0 -40
  125. package/dist/assets/RemoteAccessPage-CyQlSjPf.js +0 -1
  126. package/dist/assets/RuntimeConfig-Bk0uYBhf.js +0 -1
  127. package/dist/assets/chat-page-Bph8M5zo.js +0 -58
  128. package/dist/assets/chat-session-display-CoN3Wmn-.js +0 -1
  129. package/dist/assets/desktop-update-config-1KBrqLBC.js +0 -1
  130. package/dist/assets/i18n-CwHZ-9vt.js +0 -1
  131. package/dist/assets/index-DafCdM4F.css +0 -1
  132. package/dist/assets/index-DdksE6U3.js +0 -6
  133. package/dist/assets/loader-circle-PsSP0H9n.js +0 -1
  134. package/dist/assets/play-DBQbBxTA.js +0 -1
  135. package/dist/assets/plus-DUOVbsyQ.js +0 -1
  136. package/dist/assets/search-MChQRYR1.js +0 -1
  137. package/dist/assets/skeleton-B-4vRq_Z.js +0 -1
  138. package/dist/assets/x-DuMhMATD.js +0 -1
  139. package/src/components/chat/ChatConversationPanel.tsx +0 -256
  140. package/src/components/chat/chat-child-session-panel.tsx +0 -270
  141. /package/dist/assets/{config-hints-BZoDjXye.js → config-hints-BhTmc9P1.js} +0 -0
  142. /package/dist/assets/{config-layout-DmlGaay2.js → config-layout-CHs0mAaR.js} +0 -0
@@ -0,0 +1,110 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { beforeEach, describe, expect, it } from 'vitest';
3
+ import { PwaInstallBanner, PwaInstallCard, PwaUpdateBanner } from '@/pwa/components/pwa-install-entry';
4
+ import { usePwaStore, createInitialPwaState } from '@/pwa/stores/pwa.store';
5
+
6
+ describe('PwaInstallCard', () => {
7
+ beforeEach(() => {
8
+ usePwaStore.setState(createInitialPwaState());
9
+ });
10
+
11
+ it('renders install action when prompt install is available', () => {
12
+ usePwaStore.setState({
13
+ initialized: true,
14
+ installability: 'available',
15
+ installMethod: 'prompt',
16
+ blockedReason: null,
17
+ dismissedInstallPrompt: false,
18
+ updateAvailable: false,
19
+ registrationFailed: false
20
+ });
21
+
22
+ render(<PwaInstallCard />);
23
+
24
+ expect(screen.getByRole('button', { name: 'Install NextClaw' })).toBeTruthy();
25
+ expect(screen.getByText('Installable')).toBeTruthy();
26
+ });
27
+
28
+ it('renders desktop host description when suppressed', () => {
29
+ usePwaStore.setState({
30
+ initialized: true,
31
+ installability: 'suppressed',
32
+ installMethod: 'none',
33
+ blockedReason: 'desktop-host',
34
+ dismissedInstallPrompt: false,
35
+ updateAvailable: false,
36
+ registrationFailed: false
37
+ });
38
+
39
+ render(<PwaInstallCard />);
40
+
41
+ expect(screen.getByText('Desktop Host Active')).toBeTruthy();
42
+ expect(screen.getByText(/already running inside the Electron desktop host/i)).toBeTruthy();
43
+ });
44
+
45
+ it('does not render update banner before installation', () => {
46
+ usePwaStore.setState({
47
+ initialized: true,
48
+ installability: 'available',
49
+ installMethod: 'manual',
50
+ blockedReason: null,
51
+ dismissedInstallPrompt: false,
52
+ updateAvailable: true,
53
+ registrationFailed: false
54
+ });
55
+
56
+ const { container } = render(<PwaUpdateBanner />);
57
+
58
+ expect(container.textContent).toBe('');
59
+ });
60
+
61
+ it('renders update banner only for installed pwa', () => {
62
+ usePwaStore.setState({
63
+ initialized: true,
64
+ installability: 'installed',
65
+ installMethod: 'none',
66
+ blockedReason: null,
67
+ dismissedInstallPrompt: false,
68
+ updateAvailable: true,
69
+ registrationFailed: false
70
+ });
71
+
72
+ render(<PwaUpdateBanner />);
73
+
74
+ expect(screen.getByText('NextClaw Update Ready')).toBeTruthy();
75
+ expect(screen.getByRole('button', { name: 'Refresh Now' })).toBeTruthy();
76
+ });
77
+
78
+ it('renders install banner when prompt install is available and not dismissed', () => {
79
+ usePwaStore.setState({
80
+ initialized: true,
81
+ installability: 'available',
82
+ installMethod: 'prompt',
83
+ blockedReason: null,
84
+ dismissedInstallPrompt: false,
85
+ updateAvailable: false,
86
+ registrationFailed: false
87
+ });
88
+
89
+ render(<PwaInstallBanner />);
90
+
91
+ expect(screen.getByText('Pin NextClaw as an App')).toBeTruthy();
92
+ expect(screen.getByRole('button', { name: 'Install NextClaw' })).toBeTruthy();
93
+ });
94
+
95
+ it('does not render install banner after dismissal', () => {
96
+ usePwaStore.setState({
97
+ initialized: true,
98
+ installability: 'available',
99
+ installMethod: 'prompt',
100
+ blockedReason: null,
101
+ dismissedInstallPrompt: true,
102
+ updateAvailable: false,
103
+ registrationFailed: false
104
+ });
105
+
106
+ const { container } = render(<PwaInstallBanner />);
107
+
108
+ expect(container.textContent).toBe('');
109
+ });
110
+ });
@@ -0,0 +1,205 @@
1
+ import { Button } from '@/components/ui/button';
2
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
3
+ import { usePwaStore } from '@/pwa/stores/pwa.store';
4
+ import { pwaInstallManager } from '@/pwa/managers/pwa-install.manager';
5
+ import { pwaRuntimeManager } from '@/pwa/managers/pwa-runtime.manager';
6
+ import { t } from '@/lib/i18n';
7
+ import { cn } from '@/lib/utils';
8
+ import { Download, RefreshCw, Smartphone, X } from 'lucide-react';
9
+
10
+ function InstallStatusBadge() {
11
+ const installability = usePwaStore((state) => state.installability);
12
+
13
+ const badgeClassName =
14
+ installability === 'installed'
15
+ ? 'bg-emerald-50 text-emerald-700 border-emerald-200'
16
+ : installability === 'available'
17
+ ? 'bg-amber-50 text-amber-700 border-amber-200'
18
+ : 'bg-gray-100 text-gray-600 border-gray-200';
19
+
20
+ return (
21
+ <span className={cn('inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium', badgeClassName)}>
22
+ {resolveInstallabilityLabel(installability)}
23
+ </span>
24
+ );
25
+ }
26
+
27
+ function resolveInstallabilityLabel(installability: string): string {
28
+ if (installability === 'available') {
29
+ return t('pwaInstallStatusAvailable');
30
+ }
31
+ if (installability === 'installed') {
32
+ return t('pwaInstallStatusInstalled');
33
+ }
34
+ if (installability === 'suppressed') {
35
+ return t('pwaInstallStatusDesktopHost');
36
+ }
37
+ return t('pwaInstallStatusUnavailable');
38
+ }
39
+
40
+ function resolveCardDescription(
41
+ installability: string,
42
+ installMethod: string,
43
+ blockedReason: string | null
44
+ ) {
45
+ if (installability === 'installed') {
46
+ return t('pwaInstallCardInstalled');
47
+ }
48
+
49
+ if (installability === 'suppressed') {
50
+ return t('pwaInstallCardSuppressed');
51
+ }
52
+
53
+ if (installability === 'available' && installMethod === 'prompt') {
54
+ return t('pwaInstallCardPrompt');
55
+ }
56
+
57
+ if (installability === 'available') {
58
+ return t('pwaInstallCardManual');
59
+ }
60
+
61
+ if (blockedReason === 'insecure-context') {
62
+ return t('pwaInstallCardInsecureContext');
63
+ }
64
+
65
+ if (blockedReason === 'dev-server') {
66
+ return t('pwaInstallCardDevServer');
67
+ }
68
+
69
+ return t('pwaInstallCardUnsupported');
70
+ }
71
+
72
+ export function PwaInstallCard() {
73
+ const installability = usePwaStore((state) => state.installability);
74
+ const installMethod = usePwaStore((state) => state.installMethod);
75
+ const blockedReason = usePwaStore((state) => state.blockedReason);
76
+
77
+ return (
78
+ <Card>
79
+ <CardHeader className="flex flex-row items-start justify-between gap-4 space-y-0">
80
+ <div className="space-y-2">
81
+ <CardTitle>{t('pwaInstallTitle')}</CardTitle>
82
+ <CardDescription>{t('pwaInstallDescription')}</CardDescription>
83
+ </div>
84
+ <InstallStatusBadge />
85
+ </CardHeader>
86
+ <CardContent className="space-y-4">
87
+ <div className="rounded-2xl border border-gray-200 bg-gray-50/90 p-4">
88
+ <p className="text-sm leading-6 text-gray-700">
89
+ {resolveCardDescription(installability, installMethod, blockedReason)}
90
+ </p>
91
+ </div>
92
+
93
+ {installability === 'available' && installMethod === 'prompt' ? (
94
+ <div className="flex flex-wrap items-center gap-3">
95
+ <Button
96
+ type="button"
97
+ className="gap-2"
98
+ onClick={() => {
99
+ void pwaInstallManager.promptInstall();
100
+ }}
101
+ >
102
+ <Download className="h-4 w-4" />
103
+ {t('pwaInstallAction')}
104
+ </Button>
105
+ <p className="text-sm text-gray-500">{t('pwaInstallPromptHint')}</p>
106
+ </div>
107
+ ) : null}
108
+
109
+ {installability === 'available' && installMethod === 'manual' ? (
110
+ <div className="flex items-start gap-3 rounded-2xl border border-dashed border-gray-300 bg-white p-4">
111
+ <Smartphone className="mt-0.5 h-4 w-4 text-gray-500" />
112
+ <p className="text-sm leading-6 text-gray-600">{t('pwaInstallManualHint')}</p>
113
+ </div>
114
+ ) : null}
115
+ </CardContent>
116
+ </Card>
117
+ );
118
+ }
119
+
120
+ export function PwaInstallBanner() {
121
+ const installability = usePwaStore((state) => state.installability);
122
+ const installMethod = usePwaStore((state) => state.installMethod);
123
+ const dismissedInstallPrompt = usePwaStore((state) => state.dismissedInstallPrompt);
124
+
125
+ if (installability !== 'available' || installMethod !== 'prompt' || dismissedInstallPrompt) {
126
+ return null;
127
+ }
128
+
129
+ return (
130
+ <div className="fixed bottom-5 right-5 z-50 w-[min(420px,calc(100vw-2rem))] rounded-[26px] border border-gray-200 bg-white/95 p-5 shadow-2xl backdrop-blur-xl">
131
+ <div className="flex items-start justify-between gap-4">
132
+ <div className="space-y-1">
133
+ <p className="text-sm font-semibold text-gray-900">{t('pwaInstallBannerTitle')}</p>
134
+ <p className="text-sm leading-6 text-gray-600">{t('pwaInstallBannerDescription')}</p>
135
+ </div>
136
+ <button
137
+ type="button"
138
+ aria-label={t('pwaInstallDismiss')}
139
+ className="rounded-full p-1 text-gray-400 transition hover:bg-gray-100 hover:text-gray-600"
140
+ onClick={() => {
141
+ pwaInstallManager.dismissInstallPrompt();
142
+ }}
143
+ >
144
+ <X className="h-4 w-4" />
145
+ </button>
146
+ </div>
147
+ <div className="mt-4 flex items-center gap-3">
148
+ <Button
149
+ type="button"
150
+ size="sm"
151
+ className="gap-2"
152
+ onClick={() => {
153
+ void pwaInstallManager.promptInstall();
154
+ }}
155
+ >
156
+ <Download className="h-4 w-4" />
157
+ {t('pwaInstallAction')}
158
+ </Button>
159
+ <Button
160
+ type="button"
161
+ size="sm"
162
+ variant="ghost"
163
+ onClick={() => {
164
+ pwaInstallManager.dismissInstallPrompt();
165
+ }}
166
+ >
167
+ {t('pwaInstallDismiss')}
168
+ </Button>
169
+ </div>
170
+ </div>
171
+ );
172
+ }
173
+
174
+ export function PwaUpdateBanner() {
175
+ const updateAvailable = usePwaStore((state) => state.updateAvailable);
176
+ const installability = usePwaStore((state) => state.installability);
177
+
178
+ if (!updateAvailable || installability !== 'installed') {
179
+ return null;
180
+ }
181
+
182
+ return (
183
+ <div className="fixed top-5 right-5 z-50 w-[min(420px,calc(100vw-2rem))] rounded-[26px] border border-gray-200 bg-white/95 p-5 shadow-2xl backdrop-blur-xl">
184
+ <div className="flex items-start justify-between gap-4">
185
+ <div className="space-y-1">
186
+ <p className="text-sm font-semibold text-gray-900">{t('pwaUpdateBannerTitle')}</p>
187
+ <p className="text-sm leading-6 text-gray-600">{t('pwaUpdateBannerDescription')}</p>
188
+ </div>
189
+ </div>
190
+ <div className="mt-4">
191
+ <Button
192
+ type="button"
193
+ size="sm"
194
+ className="gap-2"
195
+ onClick={() => {
196
+ void pwaRuntimeManager.applyUpdate();
197
+ }}
198
+ >
199
+ <RefreshCw className="h-4 w-4" />
200
+ {t('pwaUpdateAction')}
201
+ </Button>
202
+ </div>
203
+ </div>
204
+ );
205
+ }
@@ -0,0 +1,160 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { PwaInstallManager } from '@/pwa/managers/pwa-install.manager';
3
+ import {
4
+ PWA_INSTALL_BANNER_DISMISS_STORAGE_KEY,
5
+ PWA_INSTALL_BANNER_LEGACY_UNTIL_STORAGE_KEY
6
+ } from '@/pwa/pwa-install-banner.storage';
7
+ import { usePwaStore, createInitialPwaState } from '@/pwa/stores/pwa.store';
8
+
9
+ function createMatchMedia(matches = false): typeof window.matchMedia {
10
+ return vi.fn().mockImplementation(() => ({
11
+ matches,
12
+ media: '(display-mode: standalone)',
13
+ onchange: null,
14
+ addListener: vi.fn(),
15
+ removeListener: vi.fn(),
16
+ addEventListener: vi.fn(),
17
+ removeEventListener: vi.fn(),
18
+ dispatchEvent: vi.fn()
19
+ }));
20
+ }
21
+
22
+ function createBeforeInstallPromptEvent(outcome: 'accepted' | 'dismissed' = 'accepted'): BeforeInstallPromptEvent {
23
+ const event = new Event('beforeinstallprompt') as BeforeInstallPromptEvent;
24
+ event.preventDefault = vi.fn();
25
+ event.prompt = vi.fn();
26
+ event.userChoice = Promise.resolve({ outcome, platform: 'web' });
27
+ return event;
28
+ }
29
+
30
+ describe('PwaInstallManager', () => {
31
+ let manager: PwaInstallManager;
32
+
33
+ beforeEach(() => {
34
+ manager = new PwaInstallManager();
35
+ usePwaStore.setState(createInitialPwaState());
36
+ Object.defineProperty(window, 'matchMedia', {
37
+ writable: true,
38
+ value: createMatchMedia(false)
39
+ });
40
+ Object.defineProperty(window, 'isSecureContext', {
41
+ configurable: true,
42
+ value: true
43
+ });
44
+ Object.defineProperty(window, 'nextclawDesktop', {
45
+ configurable: true,
46
+ value: undefined
47
+ });
48
+ Object.defineProperty(navigator, 'serviceWorker', {
49
+ configurable: true,
50
+ value: {}
51
+ });
52
+ window.localStorage.clear();
53
+ });
54
+
55
+ afterEach(() => {
56
+ manager.resetForTests();
57
+ window.localStorage.clear();
58
+ });
59
+
60
+ it('marks desktop host as suppressed', () => {
61
+ Object.defineProperty(window, 'nextclawDesktop', {
62
+ configurable: true,
63
+ value: {}
64
+ });
65
+
66
+ manager.start();
67
+
68
+ const state = usePwaStore.getState();
69
+ expect(state.installability).toBe('suppressed');
70
+ expect(state.blockedReason).toBe('desktop-host');
71
+ });
72
+
73
+ it('marks installed when display mode is standalone', () => {
74
+ Object.defineProperty(window, 'matchMedia', {
75
+ writable: true,
76
+ value: createMatchMedia(true)
77
+ });
78
+
79
+ manager.start();
80
+
81
+ const state = usePwaStore.getState();
82
+ expect(state.installability).toBe('installed');
83
+ expect(state.installMethod).toBe('none');
84
+ });
85
+
86
+ it('falls back to manual install when prompt event is unavailable', () => {
87
+ manager.start();
88
+
89
+ const state = usePwaStore.getState();
90
+ expect(state.installability).toBe('available');
91
+ expect(state.installMethod).toBe('manual');
92
+ });
93
+
94
+ it('marks insecure non-local origins as unsupported', () => {
95
+ Object.defineProperty(window, 'isSecureContext', {
96
+ configurable: true,
97
+ value: false
98
+ });
99
+ Object.defineProperty(window, 'location', {
100
+ configurable: true,
101
+ value: new URL('http://192.168.1.7:5174')
102
+ });
103
+
104
+ manager.start();
105
+
106
+ const state = usePwaStore.getState();
107
+ expect(state.installability).toBe('unsupported');
108
+ expect(state.blockedReason).toBe('insecure-context');
109
+ });
110
+
111
+ it('keeps install banner dismissed after beforeinstallprompt fires again', () => {
112
+ manager.start();
113
+ window.dispatchEvent(createBeforeInstallPromptEvent());
114
+
115
+ manager.dismissInstallPrompt();
116
+ expect(usePwaStore.getState().dismissedInstallPrompt).toBe(true);
117
+
118
+ window.dispatchEvent(createBeforeInstallPromptEvent());
119
+
120
+ expect(usePwaStore.getState().dismissedInstallPrompt).toBe(true);
121
+ expect(window.localStorage.getItem(PWA_INSTALL_BANNER_DISMISS_STORAGE_KEY)).toBeTruthy();
122
+ });
123
+
124
+ it('keeps install banner dismissed after user dismisses the native install prompt', async () => {
125
+ manager.start();
126
+ const promptEvent = createBeforeInstallPromptEvent('dismissed');
127
+ window.dispatchEvent(promptEvent);
128
+
129
+ const outcome = await manager.promptInstall();
130
+
131
+ expect(outcome).toBe('dismissed');
132
+ expect(promptEvent.prompt).toHaveBeenCalledTimes(1);
133
+ expect(usePwaStore.getState().dismissedInstallPrompt).toBe(true);
134
+ expect(window.localStorage.getItem(PWA_INSTALL_BANNER_DISMISS_STORAGE_KEY)).toBe('1');
135
+ });
136
+
137
+ it('hydrates dismissed state from persisted dismiss flag on fresh store init', () => {
138
+ window.localStorage.setItem(
139
+ PWA_INSTALL_BANNER_DISMISS_STORAGE_KEY,
140
+ '1'
141
+ );
142
+
143
+ usePwaStore.setState(createInitialPwaState());
144
+
145
+ expect(usePwaStore.getState().dismissedInstallPrompt).toBe(true);
146
+ });
147
+
148
+ it('migrates legacy snooze timestamps into the permanent dismiss flag', () => {
149
+ window.localStorage.setItem(
150
+ PWA_INSTALL_BANNER_LEGACY_UNTIL_STORAGE_KEY,
151
+ String(Date.now() + 60_000)
152
+ );
153
+
154
+ usePwaStore.setState(createInitialPwaState());
155
+
156
+ expect(usePwaStore.getState().dismissedInstallPrompt).toBe(true);
157
+ expect(window.localStorage.getItem(PWA_INSTALL_BANNER_DISMISS_STORAGE_KEY)).toBe('1');
158
+ expect(window.localStorage.getItem(PWA_INSTALL_BANNER_LEGACY_UNTIL_STORAGE_KEY)).toBeNull();
159
+ });
160
+ });
@@ -0,0 +1,232 @@
1
+ import { usePwaStore, createInitialPwaState } from '@/pwa/stores/pwa.store';
2
+ import type { PwaInstallBlockedReason, PwaInstallMethod, PwaInstallPromptOutcome, PwaInstallabilityState } from '@/pwa/pwa.types';
3
+ import { pwaRuntimeManager } from '@/pwa/managers/pwa-runtime.manager';
4
+ import {
5
+ clearPwaInstallBannerDismissal,
6
+ dismissPwaInstallBanner,
7
+ isPwaInstallBannerDismissed
8
+ } from '@/pwa/pwa-install-banner.storage';
9
+ import { t } from '@/lib/i18n';
10
+ import { toast } from 'sonner';
11
+
12
+ type InstallabilityResolution = {
13
+ installability: PwaInstallabilityState;
14
+ installMethod: PwaInstallMethod;
15
+ blockedReason: PwaInstallBlockedReason;
16
+ };
17
+
18
+ export class PwaInstallManager {
19
+ private started = false;
20
+ private deferredPrompt: BeforeInstallPromptEvent | null = null;
21
+ private displayModeMediaQuery: MediaQueryList | null = null;
22
+
23
+ start = () => {
24
+ if (this.started || typeof window === 'undefined') {
25
+ return;
26
+ }
27
+
28
+ this.started = true;
29
+ window.addEventListener('beforeinstallprompt', this.handleBeforeInstallPrompt as EventListener);
30
+ window.addEventListener('appinstalled', this.handleAppInstalled);
31
+
32
+ if (typeof window.matchMedia === 'function') {
33
+ this.displayModeMediaQuery = window.matchMedia('(display-mode: standalone)');
34
+ this.bindDisplayModeListener(this.displayModeMediaQuery, this.handleDisplayModeChanged);
35
+ }
36
+
37
+ this.refreshState();
38
+ };
39
+
40
+ stop = () => {
41
+ if (!this.started || typeof window === 'undefined') {
42
+ return;
43
+ }
44
+
45
+ window.removeEventListener('beforeinstallprompt', this.handleBeforeInstallPrompt as EventListener);
46
+ window.removeEventListener('appinstalled', this.handleAppInstalled);
47
+ if (this.displayModeMediaQuery) {
48
+ this.unbindDisplayModeListener(this.displayModeMediaQuery, this.handleDisplayModeChanged);
49
+ this.displayModeMediaQuery = null;
50
+ }
51
+
52
+ this.deferredPrompt = null;
53
+ this.started = false;
54
+ };
55
+
56
+ resetForTests = () => {
57
+ this.stop();
58
+ usePwaStore.setState(createInitialPwaState());
59
+ };
60
+
61
+ refreshState = () => {
62
+ const resolution = this.resolveInstallability();
63
+ usePwaStore.setState((state) => ({
64
+ ...state,
65
+ initialized: true,
66
+ installability: resolution.installability,
67
+ installMethod: resolution.installMethod,
68
+ blockedReason: resolution.blockedReason,
69
+ dismissedInstallPrompt: isPwaInstallBannerDismissed()
70
+ }));
71
+ void pwaRuntimeManager.syncUpdateAvailability();
72
+ };
73
+
74
+ dismissInstallPrompt = () => {
75
+ dismissPwaInstallBanner();
76
+ usePwaStore.setState({
77
+ dismissedInstallPrompt: true
78
+ });
79
+ };
80
+
81
+ promptInstall = async (): Promise<PwaInstallPromptOutcome> => {
82
+ const resolution = this.resolveInstallability();
83
+ if (resolution.installability !== 'available') {
84
+ return 'unavailable';
85
+ }
86
+
87
+ if (resolution.installMethod === 'manual' || !this.deferredPrompt) {
88
+ return 'manual';
89
+ }
90
+
91
+ this.deferredPrompt.prompt();
92
+ const result = await this.deferredPrompt.userChoice;
93
+ this.deferredPrompt = null;
94
+ if (result.outcome === 'accepted') {
95
+ clearPwaInstallBannerDismissal();
96
+ this.refreshState();
97
+ toast.success(t('pwaInstallAccepted'));
98
+ return 'accepted';
99
+ }
100
+
101
+ this.dismissInstallPrompt();
102
+ this.refreshState();
103
+ return 'dismissed';
104
+ };
105
+
106
+ private handleBeforeInstallPrompt = (event: BeforeInstallPromptEvent) => {
107
+ event.preventDefault();
108
+ this.deferredPrompt = event;
109
+ this.refreshState();
110
+ };
111
+
112
+ private handleAppInstalled = () => {
113
+ this.deferredPrompt = null;
114
+ clearPwaInstallBannerDismissal();
115
+ usePwaStore.setState({
116
+ dismissedInstallPrompt: true
117
+ });
118
+ toast.success(t('pwaInstalledToast'));
119
+ this.refreshState();
120
+ };
121
+
122
+ private handleDisplayModeChanged = () => {
123
+ this.refreshState();
124
+ };
125
+
126
+ private resolveInstallability = (): InstallabilityResolution => {
127
+ if (this.isDevelopmentServer()) {
128
+ return {
129
+ installability: 'unsupported',
130
+ installMethod: 'none',
131
+ blockedReason: 'dev-server'
132
+ };
133
+ }
134
+
135
+ if (this.hasDesktopHost()) {
136
+ return {
137
+ installability: 'suppressed',
138
+ installMethod: 'none',
139
+ blockedReason: 'desktop-host'
140
+ };
141
+ }
142
+
143
+ if (this.isStandalone()) {
144
+ return {
145
+ installability: 'installed',
146
+ installMethod: 'none',
147
+ blockedReason: null
148
+ };
149
+ }
150
+
151
+ if (!this.hasInstallSurfaceSupport()) {
152
+ return {
153
+ installability: 'unsupported',
154
+ installMethod: 'none',
155
+ blockedReason: 'missing-browser-support'
156
+ };
157
+ }
158
+
159
+ if (!this.isEligibleInstallContext()) {
160
+ return {
161
+ installability: 'unsupported',
162
+ installMethod: 'none',
163
+ blockedReason: 'insecure-context'
164
+ };
165
+ }
166
+
167
+ return {
168
+ installability: 'available',
169
+ installMethod: this.deferredPrompt ? 'prompt' : 'manual',
170
+ blockedReason: null
171
+ };
172
+ };
173
+
174
+ private hasDesktopHost = (): boolean => {
175
+ return typeof window !== 'undefined' && Boolean(window.nextclawDesktop);
176
+ };
177
+
178
+ private hasInstallSurfaceSupport = (): boolean => {
179
+ return typeof window !== 'undefined' && typeof window.matchMedia === 'function' && 'serviceWorker' in navigator;
180
+ };
181
+
182
+ private isEligibleInstallContext = (): boolean => {
183
+ if (typeof window === 'undefined') {
184
+ return false;
185
+ }
186
+ return window.isSecureContext || this.isTrustedLocalhost(window.location.hostname);
187
+ };
188
+
189
+ private isTrustedLocalhost = (hostname: string): boolean => {
190
+ return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || hostname === '[::1]';
191
+ };
192
+
193
+ private isDevelopmentServer = (): boolean => {
194
+ return typeof window !== 'undefined' && import.meta.env.DEV && !import.meta.env.VITEST;
195
+ };
196
+
197
+ private isStandalone = (): boolean => {
198
+ if (typeof window === 'undefined') {
199
+ return false;
200
+ }
201
+
202
+ const navigatorWithStandalone = window.navigator as Navigator & { standalone?: boolean };
203
+ const matchesStandalone =
204
+ typeof window.matchMedia === 'function' && window.matchMedia('(display-mode: standalone)').matches;
205
+ return matchesStandalone || navigatorWithStandalone.standalone === true;
206
+ };
207
+
208
+ private bindDisplayModeListener = (query: MediaQueryList, listener: () => void) => {
209
+ if ('addEventListener' in query) {
210
+ query.addEventListener('change', listener);
211
+ return;
212
+ }
213
+ const legacyQuery = query as MediaQueryList & {
214
+ addListener?: (callback: (event: MediaQueryListEvent) => void) => void;
215
+ };
216
+ legacyQuery.addListener?.(listener);
217
+ };
218
+
219
+ private unbindDisplayModeListener = (query: MediaQueryList, listener: () => void) => {
220
+ if ('removeEventListener' in query) {
221
+ query.removeEventListener('change', listener);
222
+ return;
223
+ }
224
+ const legacyQuery = query as MediaQueryList & {
225
+ removeListener?: (callback: (event: MediaQueryListEvent) => void) => void;
226
+ };
227
+ legacyQuery.removeListener?.(listener);
228
+ };
229
+
230
+ }
231
+
232
+ export const pwaInstallManager = new PwaInstallManager();