feat(pwa): pre-install naming, install UX, and manifest shortcuts (#554)
* feat(web-pwa): add dynamic manifest endpoint with blob fallback * feat(ui-pwa): add install prompt and manifest sync hooks * feat(settings): add web-only preinstall app name preference * fix(web-pwa): scope recent shortcuts to active project sessions --------- Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
575cfa2604
commit
ca18b8be0f
@@ -16,6 +16,8 @@ import { useSessionAutoCleanup } from '@/hooks/useSessionAutoCleanup';
|
|||||||
import { useQueuedMessageAutoSend } from '@/hooks/useQueuedMessageAutoSend';
|
import { useQueuedMessageAutoSend } from '@/hooks/useQueuedMessageAutoSend';
|
||||||
import { useRouter } from '@/hooks/useRouter';
|
import { useRouter } from '@/hooks/useRouter';
|
||||||
import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon';
|
import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon';
|
||||||
|
import { usePwaManifestSync } from '@/hooks/usePwaManifestSync';
|
||||||
|
import { usePwaInstallPrompt } from '@/hooks/usePwaInstallPrompt';
|
||||||
import { useWindowTitle } from '@/hooks/useWindowTitle';
|
import { useWindowTitle } from '@/hooks/useWindowTitle';
|
||||||
import { GitPollingProvider } from '@/hooks/useGitPolling';
|
import { GitPollingProvider } from '@/hooks/useGitPolling';
|
||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
@@ -319,6 +321,8 @@ function App({ apis }: AppProps) {
|
|||||||
useServerSessionStatus({ enabled: embeddedBackgroundWorkEnabled });
|
useServerSessionStatus({ enabled: embeddedBackgroundWorkEnabled });
|
||||||
|
|
||||||
usePushVisibilityBeacon({ enabled: embeddedBackgroundWorkEnabled });
|
usePushVisibilityBeacon({ enabled: embeddedBackgroundWorkEnabled });
|
||||||
|
usePwaManifestSync();
|
||||||
|
usePwaInstallPrompt();
|
||||||
|
|
||||||
useWindowTitle();
|
useWindowTitle();
|
||||||
|
|
||||||
|
|||||||
@@ -103,8 +103,9 @@ const ShortcutsSectionContent: React.FC = () => {
|
|||||||
// Visual section: Theme Mode, Font Size, Spacing, Corner Radius, Input Bar Offset (mobile), Nav Rail
|
// Visual section: Theme Mode, Font Size, Spacing, Corner Radius, Input Bar Offset (mobile), Nav Rail
|
||||||
const VisualSectionContent: React.FC = () => {
|
const VisualSectionContent: React.FC = () => {
|
||||||
const isVSCode = isVSCodeRuntime();
|
const isVSCode = isVSCodeRuntime();
|
||||||
const visibleSettings: Array<'theme' | 'fontSize' | 'terminalFontSize' | 'spacing' | 'cornerRadius' | 'inputBarOffset' | 'terminalQuickKeys' | 'navRail' | 'mermaidRendering' | 'userMessageRendering' | 'stickyUserHeader'> = [
|
const visibleSettings: Array<'theme' | 'pwaInstallName' | 'fontSize' | 'terminalFontSize' | 'spacing' | 'cornerRadius' | 'inputBarOffset' | 'terminalQuickKeys' | 'navRail' | 'mermaidRendering' | 'userMessageRendering' | 'stickyUserHeader'> = [
|
||||||
'theme',
|
'theme',
|
||||||
|
'pwaInstallName',
|
||||||
'fontSize',
|
'fontSize',
|
||||||
'terminalFontSize',
|
'terminalFontSize',
|
||||||
'spacing',
|
'spacing',
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { ButtonSmall } from '@/components/ui/button-small';
|
|||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { NumberInput } from '@/components/ui/number-input';
|
import { NumberInput } from '@/components/ui/number-input';
|
||||||
import { Radio } from '@/components/ui/radio';
|
import { Radio } from '@/components/ui/radio';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -18,8 +19,9 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
import { isVSCodeRuntime, isWebRuntime } from '@/lib/desktop';
|
||||||
import { useDeviceInfo } from '@/lib/device';
|
import { useDeviceInfo } from '@/lib/device';
|
||||||
|
import { usePwaDetection } from '@/hooks/usePwaDetection';
|
||||||
import { updateDesktopSettings } from '@/lib/persistence';
|
import { updateDesktopSettings } from '@/lib/persistence';
|
||||||
import {
|
import {
|
||||||
setDirectoryShowHidden,
|
setDirectoryShowHidden,
|
||||||
@@ -97,6 +99,13 @@ const MERMAID_RENDERING_OPTIONS: Option<'svg' | 'ascii'>[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const DEFAULT_PWA_INSTALL_NAME = 'OpenChamber - AI Coding Assistant';
|
||||||
|
|
||||||
|
type PwaInstallNameWindow = Window & {
|
||||||
|
__OPENCHAMBER_SET_PWA_INSTALL_NAME__?: (value: string) => string;
|
||||||
|
__OPENCHAMBER_UPDATE_PWA_MANIFEST__?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
const USER_MESSAGE_RENDERING_OPTIONS: Option<'markdown' | 'plain'>[] = [
|
const USER_MESSAGE_RENDERING_OPTIONS: Option<'markdown' | 'plain'>[] = [
|
||||||
{
|
{
|
||||||
id: 'markdown',
|
id: 'markdown',
|
||||||
@@ -114,7 +123,7 @@ const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain'
|
|||||||
return mode === 'markdown' ? 'markdown' : 'plain';
|
return mode === 'markdown' ? 'markdown' : 'plain';
|
||||||
};
|
};
|
||||||
|
|
||||||
export type VisibleSetting = 'theme' | 'fontSize' | 'terminalFontSize' | 'spacing' | 'cornerRadius' | 'inputBarOffset' | 'navRail' | 'toolOutput' | 'mermaidRendering' | 'userMessageRendering' | 'stickyUserHeader' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'reasoning' | 'queueMode' | 'textJustificationActivity' | 'terminalQuickKeys' | 'persistDraft';
|
export type VisibleSetting = 'theme' | 'pwaInstallName' | 'fontSize' | 'terminalFontSize' | 'spacing' | 'cornerRadius' | 'inputBarOffset' | 'navRail' | 'toolOutput' | 'mermaidRendering' | 'userMessageRendering' | 'stickyUserHeader' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'reasoning' | 'queueMode' | 'textJustificationActivity' | 'terminalQuickKeys' | 'persistDraft';
|
||||||
|
|
||||||
interface OpenChamberVisualSettingsProps {
|
interface OpenChamberVisualSettingsProps {
|
||||||
/** Which settings to show. If undefined, shows all. */
|
/** Which settings to show. If undefined, shows all. */
|
||||||
@@ -123,6 +132,7 @@ interface OpenChamberVisualSettingsProps {
|
|||||||
|
|
||||||
export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps> = ({ visibleSettings }) => {
|
export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps> = ({ visibleSettings }) => {
|
||||||
const { isMobile } = useDeviceInfo();
|
const { isMobile } = useDeviceInfo();
|
||||||
|
const { browserTab } = usePwaDetection();
|
||||||
const directoryShowHidden = useDirectoryShowHidden();
|
const directoryShowHidden = useDirectoryShowHidden();
|
||||||
const showReasoningTraces = useUIStore(state => state.showReasoningTraces);
|
const showReasoningTraces = useUIStore(state => state.showReasoningTraces);
|
||||||
const setShowReasoningTraces = useUIStore(state => state.setShowReasoningTraces);
|
const setShowReasoningTraces = useUIStore(state => state.setShowReasoningTraces);
|
||||||
@@ -218,7 +228,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
|||||||
};
|
};
|
||||||
|
|
||||||
const isVSCode = isVSCodeRuntime();
|
const isVSCode = isVSCodeRuntime();
|
||||||
const hasAppearanceSettings = shouldShow('theme') && !isVSCode;
|
const hasAppearanceSettings = (shouldShow('theme') || shouldShow('pwaInstallName')) && !isVSCode;
|
||||||
const hasLayoutSettings = shouldShow('fontSize') || shouldShow('terminalFontSize') || shouldShow('spacing') || shouldShow('cornerRadius') || shouldShow('inputBarOffset');
|
const hasLayoutSettings = shouldShow('fontSize') || shouldShow('terminalFontSize') || shouldShow('spacing') || shouldShow('cornerRadius') || shouldShow('inputBarOffset');
|
||||||
const hasNavigationSettings = (!isMobile && shouldShow('navRail')) || (shouldShow('terminalQuickKeys') && !isMobile);
|
const hasNavigationSettings = (!isMobile && shouldShow('navRail')) || (shouldShow('terminalQuickKeys') && !isMobile);
|
||||||
const hasBehaviorSettings = shouldShow('toolOutput')
|
const hasBehaviorSettings = shouldShow('toolOutput')
|
||||||
@@ -232,6 +242,74 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
|||||||
|| shouldShow('queueMode')
|
|| shouldShow('queueMode')
|
||||||
|| shouldShow('textJustificationActivity')
|
|| shouldShow('textJustificationActivity')
|
||||||
|| shouldShow('persistDraft');
|
|| shouldShow('persistDraft');
|
||||||
|
|
||||||
|
const showPwaInstallNameSetting = shouldShow('pwaInstallName') && isWebRuntime() && browserTab;
|
||||||
|
const [pwaInstallName, setPwaInstallName] = React.useState('');
|
||||||
|
|
||||||
|
const applyPwaInstallName = React.useCallback(async (value: string) => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const win = window as PwaInstallNameWindow;
|
||||||
|
const normalized = value.trim().replace(/\s+/g, ' ').slice(0, 64);
|
||||||
|
const persistedValue = normalized;
|
||||||
|
|
||||||
|
await updateDesktopSettings({ pwaAppName: persistedValue });
|
||||||
|
|
||||||
|
if (typeof win.__OPENCHAMBER_SET_PWA_INSTALL_NAME__ === 'function') {
|
||||||
|
const resolved = win.__OPENCHAMBER_SET_PWA_INSTALL_NAME__(persistedValue);
|
||||||
|
setPwaInstallName(resolved);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPwaInstallName(persistedValue || DEFAULT_PWA_INSTALL_NAME);
|
||||||
|
win.__OPENCHAMBER_UPDATE_PWA_MANIFEST__?.();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (typeof window === 'undefined' || !showPwaInstallNameSetting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const loadPwaInstallName = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/config/settings', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setPwaInstallName(DEFAULT_PWA_INSTALL_NAME);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = await response.json().catch(() => ({}));
|
||||||
|
const raw = typeof settings?.pwaAppName === 'string' ? settings.pwaAppName : '';
|
||||||
|
const normalized = raw.trim().replace(/\s+/g, ' ').slice(0, 64);
|
||||||
|
|
||||||
|
if (!cancelled) {
|
||||||
|
setPwaInstallName(normalized || DEFAULT_PWA_INSTALL_NAME);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) {
|
||||||
|
setPwaInstallName(DEFAULT_PWA_INSTALL_NAME);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void loadPwaInstallName();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [showPwaInstallNameSetting]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
|
|
||||||
@@ -333,6 +411,48 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
|||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showPwaInstallNameSetting && (
|
||||||
|
<div className={cn('py-1.5', isMobile ? 'space-y-2' : 'flex items-center gap-8')}>
|
||||||
|
<div className={cn('flex min-w-0 flex-col', isMobile ? 'w-full' : 'w-56 shrink-0')}>
|
||||||
|
<span className="typography-ui-label text-foreground">Install App Name</span>
|
||||||
|
<span className="typography-meta text-muted-foreground">Used by Chrome install prompt before install.</span>
|
||||||
|
</div>
|
||||||
|
<div className={cn('flex items-center gap-2', isMobile ? 'w-full' : 'w-fit min-w-[22rem]')}>
|
||||||
|
<Input
|
||||||
|
value={pwaInstallName}
|
||||||
|
onChange={(event) => {
|
||||||
|
setPwaInstallName(event.target.value);
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
void applyPwaInstallName(pwaInstallName);
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
void applyPwaInstallName(pwaInstallName);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="h-7"
|
||||||
|
maxLength={64}
|
||||||
|
aria-label="PWA install app name"
|
||||||
|
/>
|
||||||
|
<ButtonSmall
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setPwaInstallName(DEFAULT_PWA_INSTALL_NAME);
|
||||||
|
void applyPwaInstallName('');
|
||||||
|
}}
|
||||||
|
className="h-7 w-7 px-0 text-muted-foreground hover:text-foreground"
|
||||||
|
aria-label="Reset install app name"
|
||||||
|
title="Reset"
|
||||||
|
>
|
||||||
|
<RiRestartLine className="h-3.5 w-3.5" />
|
||||||
|
</ButtonSmall>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { getPWADisplayMode, type PWADisplayMode } from '@/lib/pwa';
|
||||||
|
|
||||||
|
type PwaDetectionState = {
|
||||||
|
displayMode: PWADisplayMode;
|
||||||
|
installed: boolean;
|
||||||
|
browserTab: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getState = (): PwaDetectionState => {
|
||||||
|
const displayMode = getPWADisplayMode();
|
||||||
|
return {
|
||||||
|
displayMode,
|
||||||
|
installed: displayMode !== 'browser',
|
||||||
|
browserTab: displayMode === 'browser',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const usePwaDetection = (): PwaDetectionState => {
|
||||||
|
const [state, setState] = React.useState<PwaDetectionState>(() => getState());
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const queries = [
|
||||||
|
window.matchMedia('(display-mode: standalone)'),
|
||||||
|
window.matchMedia('(display-mode: minimal-ui)'),
|
||||||
|
window.matchMedia('(display-mode: fullscreen)'),
|
||||||
|
window.matchMedia('(display-mode: window-controls-overlay)'),
|
||||||
|
];
|
||||||
|
|
||||||
|
const onChange = () => {
|
||||||
|
setState(getState());
|
||||||
|
};
|
||||||
|
|
||||||
|
onChange();
|
||||||
|
|
||||||
|
for (const query of queries) {
|
||||||
|
query.addEventListener('change', onChange);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('appinstalled', onChange);
|
||||||
|
window.addEventListener('focus', onChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
for (const query of queries) {
|
||||||
|
query.removeEventListener('change', onChange);
|
||||||
|
}
|
||||||
|
window.removeEventListener('appinstalled', onChange);
|
||||||
|
window.removeEventListener('focus', onChange);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return state;
|
||||||
|
};
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { toast } from '@/components/ui';
|
||||||
|
import { isWebRuntime } from '@/lib/desktop';
|
||||||
|
import { usePwaDetection } from '@/hooks/usePwaDetection';
|
||||||
|
|
||||||
|
type InstallPromptOutcome = 'accepted' | 'dismissed';
|
||||||
|
|
||||||
|
type BeforeInstallPromptEvent = Event & {
|
||||||
|
prompt: () => Promise<void>;
|
||||||
|
userChoice: Promise<{ outcome: InstallPromptOutcome }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const usePwaInstallPrompt = () => {
|
||||||
|
const { browserTab } = usePwaDetection();
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (typeof window === 'undefined' || !isWebRuntime() || !browserTab) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let deferredPrompt: BeforeInstallPromptEvent | null = null;
|
||||||
|
let installToastId: string | number | null = null;
|
||||||
|
|
||||||
|
const dismissInstallToast = () => {
|
||||||
|
if (installToastId === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.dismiss(installToastId);
|
||||||
|
installToastId = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const triggerInstall = async () => {
|
||||||
|
if (!deferredPrompt) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const promptEvent = deferredPrompt;
|
||||||
|
deferredPrompt = null;
|
||||||
|
dismissInstallToast();
|
||||||
|
|
||||||
|
await promptEvent.prompt();
|
||||||
|
const { outcome } = await promptEvent.userChoice;
|
||||||
|
if (outcome === 'accepted') {
|
||||||
|
toast.success('Install started');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onBeforeInstallPrompt = (event: Event) => {
|
||||||
|
const installEvent = event as BeforeInstallPromptEvent;
|
||||||
|
if (typeof installEvent.prompt !== 'function') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
installEvent.preventDefault();
|
||||||
|
deferredPrompt = installEvent;
|
||||||
|
|
||||||
|
if (installToastId !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
installToastId = toast.info('Install OpenChamber for quicker access', {
|
||||||
|
duration: Infinity,
|
||||||
|
action: {
|
||||||
|
label: 'Install',
|
||||||
|
onClick: () => {
|
||||||
|
void triggerInstall();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onAppInstalled = () => {
|
||||||
|
deferredPrompt = null;
|
||||||
|
dismissInstallToast();
|
||||||
|
toast.success('OpenChamber installed');
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('beforeinstallprompt', onBeforeInstallPrompt as EventListener);
|
||||||
|
window.addEventListener('appinstalled', onAppInstalled);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
dismissInstallToast();
|
||||||
|
window.removeEventListener('beforeinstallprompt', onBeforeInstallPrompt as EventListener);
|
||||||
|
window.removeEventListener('appinstalled', onAppInstalled);
|
||||||
|
};
|
||||||
|
}, [browserTab]);
|
||||||
|
};
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useSessionStore } from '@/stores/useSessionStore';
|
||||||
|
import { isWebRuntime } from '@/lib/desktop';
|
||||||
|
import { PWA_RECENT_SESSIONS_STORAGE_KEY } from '@/lib/pwa';
|
||||||
|
|
||||||
|
type RecentSessionShortcut = {
|
||||||
|
sessionId: string;
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ManifestSyncWindow = Window & {
|
||||||
|
__OPENCHAMBER_UPDATE_PWA_MANIFEST__?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAX_RECENT_SHORTCUTS = 3;
|
||||||
|
|
||||||
|
const normalizeRecentTitle = (value: string | undefined, fallback: string): string => {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
const normalized = value.trim().replace(/\s+/g, ' ');
|
||||||
|
if (!normalized) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
return normalized.slice(0, 48);
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildRecentShortcuts = (
|
||||||
|
sessions: Array<{ id: string; title?: string }>,
|
||||||
|
currentSessionId: string | null,
|
||||||
|
): RecentSessionShortcut[] => {
|
||||||
|
const ordered = currentSessionId
|
||||||
|
? [
|
||||||
|
...sessions.filter((session) => session.id === currentSessionId),
|
||||||
|
...sessions.filter((session) => session.id !== currentSessionId),
|
||||||
|
]
|
||||||
|
: sessions;
|
||||||
|
|
||||||
|
const shortcuts: RecentSessionShortcut[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
for (const session of ordered) {
|
||||||
|
const sessionId = typeof session.id === 'string' ? session.id.trim() : '';
|
||||||
|
if (!sessionId || seen.has(sessionId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
seen.add(sessionId);
|
||||||
|
shortcuts.push({
|
||||||
|
sessionId,
|
||||||
|
title: normalizeRecentTitle(session.title, `Session ${shortcuts.length + 1}`),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (shortcuts.length >= MAX_RECENT_SHORTCUTS) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return shortcuts;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const usePwaManifestSync = () => {
|
||||||
|
const sessions = useSessionStore((state) => state.sessions);
|
||||||
|
const currentSessionId = useSessionStore((state) => state.currentSessionId);
|
||||||
|
|
||||||
|
const recentShortcuts = React.useMemo(() => {
|
||||||
|
return buildRecentShortcuts(sessions, currentSessionId);
|
||||||
|
}, [currentSessionId, sessions]);
|
||||||
|
|
||||||
|
const signature = React.useMemo(() => JSON.stringify(recentShortcuts), [recentShortcuts]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (typeof window === 'undefined' || !isWebRuntime()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (recentShortcuts.length === 0) {
|
||||||
|
localStorage.removeItem(PWA_RECENT_SESSIONS_STORAGE_KEY);
|
||||||
|
} else {
|
||||||
|
localStorage.setItem(PWA_RECENT_SESSIONS_STORAGE_KEY, signature);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const win = window as ManifestSyncWindow;
|
||||||
|
win.__OPENCHAMBER_UPDATE_PWA_MANIFEST__?.();
|
||||||
|
}, [recentShortcuts, signature]);
|
||||||
|
};
|
||||||
@@ -537,6 +537,7 @@ export interface SettingsPayload {
|
|||||||
openInAppId?: string;
|
openInAppId?: string;
|
||||||
gitProviderId?: string;
|
gitProviderId?: string;
|
||||||
gitModelId?: string;
|
gitModelId?: string;
|
||||||
|
pwaAppName?: string;
|
||||||
|
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ export type DesktopSettings = {
|
|||||||
zenModel?: string;
|
zenModel?: string;
|
||||||
gitProviderId?: string;
|
gitProviderId?: string;
|
||||||
gitModelId?: string;
|
gitModelId?: string;
|
||||||
|
pwaAppName?: string;
|
||||||
toolCallExpansion?: 'collapsed' | 'activity' | 'detailed';
|
toolCallExpansion?: 'collapsed' | 'activity' | 'detailed';
|
||||||
userMessageRenderingMode?: 'markdown' | 'plain';
|
userMessageRenderingMode?: 'markdown' | 'plain';
|
||||||
stickyUserHeader?: boolean;
|
stickyUserHeader?: boolean;
|
||||||
|
|||||||
@@ -74,6 +74,14 @@ const persistToLocalStorage = (settings: DesktopSettings) => {
|
|||||||
if (typeof settings.openInAppId === 'string' && settings.openInAppId.length > 0) {
|
if (typeof settings.openInAppId === 'string' && settings.openInAppId.length > 0) {
|
||||||
localStorage.setItem('openInAppId', settings.openInAppId);
|
localStorage.setItem('openInAppId', settings.openInAppId);
|
||||||
}
|
}
|
||||||
|
if (typeof settings.pwaAppName === 'string') {
|
||||||
|
const normalized = settings.pwaAppName.trim().replace(/\s+/g, ' ').slice(0, 64);
|
||||||
|
if (normalized.length > 0) {
|
||||||
|
localStorage.setItem('openchamber.pwaName', normalized);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem('openchamber.pwaName');
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
type PersistApi = {
|
type PersistApi = {
|
||||||
@@ -784,6 +792,10 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
|
|||||||
if (typeof candidate.openInAppId === 'string' && candidate.openInAppId.length > 0) {
|
if (typeof candidate.openInAppId === 'string' && candidate.openInAppId.length > 0) {
|
||||||
result.openInAppId = candidate.openInAppId;
|
result.openInAppId = candidate.openInAppId;
|
||||||
}
|
}
|
||||||
|
if (typeof candidate.pwaAppName === 'string') {
|
||||||
|
const normalized = candidate.pwaAppName.trim().replace(/\s+/g, ' ').slice(0, 64);
|
||||||
|
result.pwaAppName = normalized.length > 0 ? normalized : '';
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof candidate.messageLimit === 'number' && Number.isFinite(candidate.messageLimit)) {
|
if (typeof candidate.messageLimit === 'number' && Number.isFinite(candidate.messageLimit)) {
|
||||||
result.messageLimit = candidate.messageLimit;
|
result.messageLimit = candidate.messageLimit;
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
export type PWADisplayMode =
|
||||||
|
| 'browser'
|
||||||
|
| 'standalone'
|
||||||
|
| 'minimal-ui'
|
||||||
|
| 'fullscreen'
|
||||||
|
| 'window-controls-overlay'
|
||||||
|
| 'twa';
|
||||||
|
|
||||||
|
const DISPLAY_MODES: Array<Exclude<PWADisplayMode, 'browser' | 'twa'>> = ['standalone', 'minimal-ui', 'fullscreen', 'window-controls-overlay'];
|
||||||
|
|
||||||
|
export const PWA_INSTALL_NAME_STORAGE_KEY = 'openchamber.pwaName';
|
||||||
|
export const PWA_RECENT_SESSIONS_STORAGE_KEY = 'openchamber.pwaRecentSessions';
|
||||||
|
|
||||||
|
const matchesDisplayMode = (mode: Exclude<PWADisplayMode, 'browser' | 'twa'>): boolean => {
|
||||||
|
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return window.matchMedia(`(display-mode: ${mode})`).matches;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getPWADisplayMode = (): PWADisplayMode => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return 'browser';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof document !== 'undefined' && document.referrer.startsWith('android-app://')) {
|
||||||
|
return 'twa';
|
||||||
|
}
|
||||||
|
|
||||||
|
const navigatorStandalone = Boolean((window.navigator as Navigator & { standalone?: boolean }).standalone);
|
||||||
|
if (navigatorStandalone) {
|
||||||
|
return 'standalone';
|
||||||
|
}
|
||||||
|
|
||||||
|
const matched = DISPLAY_MODES.find((mode) => matchesDisplayMode(mode));
|
||||||
|
return matched ?? 'browser';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isInstalledPWARuntime = (): boolean => {
|
||||||
|
return getPWADisplayMode() !== 'browser';
|
||||||
|
};
|
||||||
@@ -143,7 +143,7 @@ export const SETTINGS_PAGE_METADATA: readonly SettingsPageMeta[] = [
|
|||||||
title: 'Appearance',
|
title: 'Appearance',
|
||||||
group: 'appearance',
|
group: 'appearance',
|
||||||
kind: 'single',
|
kind: 'single',
|
||||||
keywords: ['theme', 'font', 'spacing', 'padding', 'corner radius', 'radius', 'input bar', 'terminal'],
|
keywords: ['theme', 'font', 'spacing', 'padding', 'corner radius', 'radius', 'input bar', 'terminal', 'pwa', 'install name', 'app shortcuts'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
slug: 'chat',
|
slug: 'chat',
|
||||||
|
|||||||
+264
-28
@@ -21,39 +21,275 @@
|
|||||||
<link rel="preload" href="https://cdn.jsdelivr.net/gh/mshaugh/nerdfont-webfonts@v3.3.0/build/fonts/FiraCodeNerdFont-Regular.woff2"
|
<link rel="preload" href="https://cdn.jsdelivr.net/gh/mshaugh/nerdfont-webfonts@v3.3.0/build/fonts/FiraCodeNerdFont-Regular.woff2"
|
||||||
as="font" type="font/woff2" crossorigin="anonymous">
|
as="font" type="font/woff2" crossorigin="anonymous">
|
||||||
|
|
||||||
<!-- Web app manifest (data URL to avoid nginx auth issues) -->
|
<!-- Web app manifest (endpoint-first with data URL fallback) -->
|
||||||
<script>
|
<script>
|
||||||
const baseUrl = location.origin;
|
const baseUrl = location.origin;
|
||||||
const manifest = {
|
const defaultAppName = 'OpenChamber - AI Coding Assistant';
|
||||||
"name": "OpenChamber - AI Coding Assistant",
|
const defaultShortName = 'OpenChamber';
|
||||||
"short_name": "OpenChamber",
|
const pwaNameStorageKey = 'openchamber.pwaName';
|
||||||
"description": "Web interface companion for OpenCode AI coding agent",
|
const pwaRecentSessionsStorageKey = 'openchamber.pwaRecentSessions';
|
||||||
"start_url": baseUrl + "/",
|
|
||||||
"display": "standalone",
|
const normalizePwaName = (value, fallback) => {
|
||||||
"background_color": "#151313",
|
if (typeof value !== 'string') {
|
||||||
"theme_color": "#edb449",
|
return fallback;
|
||||||
"orientation": "any",
|
}
|
||||||
"icons": [
|
const normalized = value.trim().replace(/\s+/g, ' ');
|
||||||
{ "src": baseUrl + "/pwa-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
if (!normalized) {
|
||||||
{ "src": baseUrl + "/pwa-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
return fallback;
|
||||||
{ "src": baseUrl + "/pwa-maskable-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
|
}
|
||||||
{ "src": baseUrl + "/pwa-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" },
|
return normalized.slice(0, 64);
|
||||||
{ "src": baseUrl + "/apple-touch-icon-180x180.png", "sizes": "180x180", "type": "image/png", "purpose": "any" },
|
|
||||||
{ "src": baseUrl + "/apple-touch-icon-152x152.png", "sizes": "152x152", "type": "image/png", "purpose": "any" },
|
|
||||||
{ "src": baseUrl + "/favicon-32.png", "sizes": "32x32", "type": "image/png" },
|
|
||||||
{ "src": baseUrl + "/favicon-16.png", "sizes": "16x16", "type": "image/png" }
|
|
||||||
],
|
|
||||||
"categories": ["developer", "tools", "productivity"],
|
|
||||||
"lang": "en"
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const manifestBlob = new Blob([JSON.stringify(manifest)], {type: 'application/manifest+json'});
|
const truncate = (value, maxLength) => {
|
||||||
const manifestURL = URL.createObjectURL(manifestBlob);
|
if (typeof value !== 'string') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return value.length > maxLength ? value.slice(0, maxLength) : value;
|
||||||
|
};
|
||||||
|
|
||||||
const link = document.createElement('link');
|
const getStoredInstallName = () => {
|
||||||
link.rel = 'manifest';
|
try {
|
||||||
link.href = manifestURL;
|
const storedName = localStorage.getItem(pwaNameStorageKey);
|
||||||
document.head.appendChild(link);
|
return normalizePwaName(storedName, defaultAppName);
|
||||||
|
} catch {
|
||||||
|
return defaultAppName;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setStoredInstallName = (value) => {
|
||||||
|
const normalizedName = normalizePwaName(value, '');
|
||||||
|
try {
|
||||||
|
if (normalizedName) {
|
||||||
|
localStorage.setItem(pwaNameStorageKey, normalizedName);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(pwaNameStorageKey);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return defaultAppName;
|
||||||
|
}
|
||||||
|
return normalizedName || defaultAppName;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getQueryInstallNameOverride = () => {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
const queryName = params.get('pwa_name') ?? params.get('app_name') ?? params.get('appName');
|
||||||
|
if (queryName === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedQueryName = normalizePwaName(queryName, '');
|
||||||
|
if (normalizedQueryName) {
|
||||||
|
localStorage.setItem(pwaNameStorageKey, normalizedQueryName);
|
||||||
|
return normalizedQueryName;
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.removeItem(pwaNameStorageKey);
|
||||||
|
return defaultAppName;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseRecentSessionShortcuts = () => {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(pwaRecentSessionsStorageKey);
|
||||||
|
if (!raw) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const seen = new Set();
|
||||||
|
const recentSessions = [];
|
||||||
|
|
||||||
|
for (const item of parsed) {
|
||||||
|
if (!item || typeof item !== 'object') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionId = typeof item.sessionId === 'string' ? item.sessionId.trim().slice(0, 160) : '';
|
||||||
|
if (!sessionId || seen.has(sessionId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackTitle = `Session ${recentSessions.length + 1}`;
|
||||||
|
const title = truncate(normalizePwaName(item.title, fallbackTitle), 48);
|
||||||
|
|
||||||
|
seen.add(sessionId);
|
||||||
|
recentSessions.push({ sessionId, title });
|
||||||
|
|
||||||
|
if (recentSessions.length >= 3) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return recentSessions;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildShortcuts = (recentSessions) => {
|
||||||
|
const shortcuts = [
|
||||||
|
{
|
||||||
|
name: 'Appearance Settings',
|
||||||
|
short_name: 'Settings',
|
||||||
|
description: 'Open appearance settings',
|
||||||
|
url: `${baseUrl}/?settings=appearance`,
|
||||||
|
icons: [{ src: `${baseUrl}/pwa-192.png`, sizes: '192x192', type: 'image/png' }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const session of recentSessions) {
|
||||||
|
const sessionTitle = truncate(session.title, 32);
|
||||||
|
shortcuts.push({
|
||||||
|
name: sessionTitle,
|
||||||
|
short_name: sessionTitle,
|
||||||
|
description: 'Open recent session',
|
||||||
|
url: `${baseUrl}/?session=${encodeURIComponent(session.sessionId)}`,
|
||||||
|
icons: [{ src: `${baseUrl}/pwa-192.png`, sizes: '192x192', type: 'image/png' }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return shortcuts;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildManifest = (appName, recentSessions) => {
|
||||||
|
const shortName = appName === defaultAppName ? defaultShortName : truncate(appName, 30);
|
||||||
|
return {
|
||||||
|
name: appName,
|
||||||
|
short_name: shortName,
|
||||||
|
description: 'Web interface companion for OpenCode AI coding agent',
|
||||||
|
id: `${baseUrl}/`,
|
||||||
|
start_url: `${baseUrl}/`,
|
||||||
|
scope: `${baseUrl}/`,
|
||||||
|
display: 'standalone',
|
||||||
|
background_color: '#151313',
|
||||||
|
theme_color: '#edb449',
|
||||||
|
orientation: 'any',
|
||||||
|
icons: [
|
||||||
|
{ src: `${baseUrl}/pwa-192.png`, sizes: '192x192', type: 'image/png', purpose: 'any' },
|
||||||
|
{ src: `${baseUrl}/pwa-512.png`, sizes: '512x512', type: 'image/png', purpose: 'any' },
|
||||||
|
{ src: `${baseUrl}/pwa-maskable-192.png`, sizes: '192x192', type: 'image/png', purpose: 'any maskable' },
|
||||||
|
{ src: `${baseUrl}/pwa-maskable-512.png`, sizes: '512x512', type: 'image/png', purpose: 'any maskable' },
|
||||||
|
{ src: `${baseUrl}/apple-touch-icon-180x180.png`, sizes: '180x180', type: 'image/png', purpose: 'any' },
|
||||||
|
{ src: `${baseUrl}/apple-touch-icon-152x152.png`, sizes: '152x152', type: 'image/png', purpose: 'any' },
|
||||||
|
{ src: `${baseUrl}/favicon-32.png`, sizes: '32x32', type: 'image/png' },
|
||||||
|
{ src: `${baseUrl}/favicon-16.png`, sizes: '16x16', type: 'image/png' },
|
||||||
|
],
|
||||||
|
shortcuts: buildShortcuts(recentSessions),
|
||||||
|
categories: ['developer', 'tools', 'productivity'],
|
||||||
|
lang: 'en',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildManifestEndpointUrl = (installNameOverride = null) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (typeof installNameOverride === 'string') {
|
||||||
|
params.set('appName', installNameOverride);
|
||||||
|
}
|
||||||
|
const search = params.toString();
|
||||||
|
return `${baseUrl}/manifest.webmanifest${search ? `?${search}` : ''}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const manifestLink = document.createElement('link');
|
||||||
|
manifestLink.rel = 'manifest';
|
||||||
|
document.head.appendChild(manifestLink);
|
||||||
|
|
||||||
|
let activeManifestBlobUrl = null;
|
||||||
|
let manifestRequestVersion = 0;
|
||||||
|
|
||||||
|
const setManifestFromBlob = (manifest) => {
|
||||||
|
if (activeManifestBlobUrl) {
|
||||||
|
URL.revokeObjectURL(activeManifestBlobUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
const manifestBlob = new Blob([JSON.stringify(manifest)], { type: 'application/manifest+json' });
|
||||||
|
activeManifestBlobUrl = URL.createObjectURL(manifestBlob);
|
||||||
|
manifestLink.href = activeManifestBlobUrl;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setManifestFromEndpoint = (manifestUrl) => {
|
||||||
|
if (activeManifestBlobUrl) {
|
||||||
|
URL.revokeObjectURL(activeManifestBlobUrl);
|
||||||
|
activeManifestBlobUrl = null;
|
||||||
|
}
|
||||||
|
manifestLink.href = manifestUrl;
|
||||||
|
};
|
||||||
|
|
||||||
|
const canUseManifestEndpoint = async (manifestUrl, requestVersion) => {
|
||||||
|
if (typeof fetch !== 'function') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = typeof AbortController === 'function' ? new AbortController() : null;
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
controller?.abort();
|
||||||
|
}, 1500);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(manifestUrl, {
|
||||||
|
credentials: 'include',
|
||||||
|
cache: 'no-store',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/manifest+json, application/json;q=0.9, */*;q=0.1',
|
||||||
|
},
|
||||||
|
...(controller ? { signal: controller.signal } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (requestVersion !== manifestRequestVersion || !response.ok) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = response.headers.get('content-type') || '';
|
||||||
|
return /manifest|json/i.test(contentType);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateManifest = async (installNameOverride = null) => {
|
||||||
|
const resolvedFallbackName = typeof installNameOverride === 'string' ? installNameOverride : getStoredInstallName();
|
||||||
|
const recentSessions = parseRecentSessionShortcuts();
|
||||||
|
const manifest = buildManifest(resolvedFallbackName, recentSessions);
|
||||||
|
const manifestUrl = buildManifestEndpointUrl(installNameOverride);
|
||||||
|
const requestVersion = ++manifestRequestVersion;
|
||||||
|
|
||||||
|
const useEndpoint = await canUseManifestEndpoint(manifestUrl, requestVersion);
|
||||||
|
if (requestVersion !== manifestRequestVersion) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (useEndpoint) {
|
||||||
|
setManifestFromEndpoint(manifestUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setManifestFromBlob(manifest);
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshManifestFromStorage = () => {
|
||||||
|
void updateManifest();
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialInstallNameOverride = getQueryInstallNameOverride();
|
||||||
|
void updateManifest(initialInstallNameOverride);
|
||||||
|
|
||||||
|
window.__OPENCHAMBER_GET_PWA_INSTALL_NAME__ = () => getStoredInstallName();
|
||||||
|
window.__OPENCHAMBER_SET_PWA_INSTALL_NAME__ = (value) => {
|
||||||
|
const resolvedName = setStoredInstallName(value);
|
||||||
|
void updateManifest(resolvedName);
|
||||||
|
return resolvedName;
|
||||||
|
};
|
||||||
|
window.__OPENCHAMBER_UPDATE_PWA_MANIFEST__ = () => {
|
||||||
|
refreshManifestFromStorage();
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
@@ -1760,6 +1760,20 @@ const sanitizeProjects = (input) => {
|
|||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const DEFAULT_PWA_APP_NAME = 'OpenChamber - AI Coding Assistant';
|
||||||
|
const PWA_APP_NAME_MAX_LENGTH = 64;
|
||||||
|
|
||||||
|
const normalizePwaAppName = (value, fallback = '') => {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
const normalized = value.trim().replace(/\s+/g, ' ');
|
||||||
|
if (!normalized) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
return normalized.slice(0, PWA_APP_NAME_MAX_LENGTH);
|
||||||
|
};
|
||||||
|
|
||||||
const sanitizeSettingsUpdate = (payload) => {
|
const sanitizeSettingsUpdate = (payload) => {
|
||||||
if (!payload || typeof payload !== 'object') {
|
if (!payload || typeof payload !== 'object') {
|
||||||
return {};
|
return {};
|
||||||
@@ -1978,6 +1992,9 @@ const sanitizeSettingsUpdate = (payload) => {
|
|||||||
const trimmed = candidate.gitModelId.trim();
|
const trimmed = candidate.gitModelId.trim();
|
||||||
result.gitModelId = trimmed.length > 0 ? trimmed : undefined;
|
result.gitModelId = trimmed.length > 0 ? trimmed : undefined;
|
||||||
}
|
}
|
||||||
|
if (typeof candidate.pwaAppName === 'string') {
|
||||||
|
result.pwaAppName = normalizePwaAppName(candidate.pwaAppName, undefined);
|
||||||
|
}
|
||||||
if (typeof candidate.toolCallExpansion === 'string') {
|
if (typeof candidate.toolCallExpansion === 'string') {
|
||||||
const mode = candidate.toolCallExpansion.trim();
|
const mode = candidate.toolCallExpansion.trim();
|
||||||
if (mode === 'collapsed' || mode === 'activity' || mode === 'detailed') {
|
if (mode === 'collapsed' || mode === 'activity' || mode === 'detailed') {
|
||||||
@@ -2228,10 +2245,12 @@ const formatSettingsResponse = (settings) => {
|
|||||||
const approved = normalizeStringArray(settings.approvedDirectories);
|
const approved = normalizeStringArray(settings.approvedDirectories);
|
||||||
const bookmarks = normalizeStringArray(settings.securityScopedBookmarks);
|
const bookmarks = normalizeStringArray(settings.securityScopedBookmarks);
|
||||||
const hasNamedTunnelToken = typeof settings?.namedTunnelToken === 'string' && settings.namedTunnelToken.trim().length > 0;
|
const hasNamedTunnelToken = typeof settings?.namedTunnelToken === 'string' && settings.namedTunnelToken.trim().length > 0;
|
||||||
|
const pwaAppName = normalizePwaAppName(settings?.pwaAppName, '');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...sanitized,
|
...sanitized,
|
||||||
hasNamedTunnelToken,
|
hasNamedTunnelToken,
|
||||||
|
...(pwaAppName ? { pwaAppName } : {}),
|
||||||
approvedDirectories: approved,
|
approvedDirectories: approved,
|
||||||
securityScopedBookmarks: bookmarks,
|
securityScopedBookmarks: bookmarks,
|
||||||
pinnedDirectories: normalizeStringArray(settings.pinnedDirectories),
|
pinnedDirectories: normalizeStringArray(settings.pinnedDirectories),
|
||||||
@@ -13119,9 +13138,230 @@ async function main(options = {}) {
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Alias for PWA manifest (.webmanifest redirect → /site.webmanifest)
|
const recentPwaSessionsCache = new Map();
|
||||||
app.get('/manifest.webmanifest', (req, res) => {
|
|
||||||
res.redirect(301, '/site.webmanifest');
|
const getRecentPwaSessionShortcuts = async (req) => {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
const resolvedDirectoryResult = await resolveProjectDirectory(req).catch(() => ({ directory: null }));
|
||||||
|
const preferredDirectory = typeof resolvedDirectoryResult?.directory === 'string'
|
||||||
|
? resolvedDirectoryResult.directory
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const cacheKey = preferredDirectory ? `dir:${preferredDirectory}` : 'global';
|
||||||
|
const cached = recentPwaSessionsCache.get(cacheKey);
|
||||||
|
if (cached && now - cached.at < 5000) {
|
||||||
|
return cached.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeShortcutTitle = (value, fallback) => {
|
||||||
|
const normalized = normalizePwaAppName(value, fallback);
|
||||||
|
return normalized.length > 48 ? normalized.slice(0, 48) : normalized;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toFiniteNumber = (value) => {
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === 'string' && value.trim().length > 0) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (Number.isFinite(parsed)) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeDirectory = (value) => {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const normalized = trimmed.replace(/\\/g, '/');
|
||||||
|
if (normalized === '/') {
|
||||||
|
return '/';
|
||||||
|
}
|
||||||
|
return normalized.length > 1 ? normalized.replace(/\/+$/, '') : normalized;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sessionUpdatedAt = (session) => {
|
||||||
|
const time = session && typeof session.time === 'object' ? session.time : null;
|
||||||
|
return toFiniteNumber(time?.updated) ?? toFiniteNumber(time?.created) ?? 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const filterSessionsByDirectory = (sessions, directory) => {
|
||||||
|
const normalizedDirectory = normalizeDirectory(directory);
|
||||||
|
if (!normalizedDirectory) {
|
||||||
|
return sessions;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefix = normalizedDirectory === '/' ? '/' : `${normalizedDirectory}/`;
|
||||||
|
return sessions.filter((session) => {
|
||||||
|
const sessionDirectory = normalizeDirectory(session?.directory);
|
||||||
|
if (!sessionDirectory) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return sessionDirectory === normalizedDirectory || (prefix !== '/' && sessionDirectory.startsWith(prefix));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const listSessions = async (directory) => {
|
||||||
|
const query = (() => {
|
||||||
|
if (typeof directory !== 'string' || directory.length === 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const preparedDirectory = process.platform === 'win32'
|
||||||
|
? directory.replace(/\//g, '\\')
|
||||||
|
: directory;
|
||||||
|
return `?directory=${encodeURIComponent(preparedDirectory)}`;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const response = await fetch(buildOpenCodeUrl(`/session${query}`, ''), {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
...getOpenCodeAuthHeaders(),
|
||||||
|
},
|
||||||
|
signal: AbortSignal.timeout(2500),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await response.json().catch(() => null);
|
||||||
|
return Array.isArray(payload) ? payload : [];
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
let payload = [];
|
||||||
|
|
||||||
|
if (preferredDirectory) {
|
||||||
|
const scopedPayload = await listSessions(preferredDirectory);
|
||||||
|
const filteredScopedPayload = filterSessionsByDirectory(scopedPayload, preferredDirectory);
|
||||||
|
|
||||||
|
if (filteredScopedPayload.length > 0) {
|
||||||
|
payload = filteredScopedPayload;
|
||||||
|
} else {
|
||||||
|
const globalPayload = await listSessions(null);
|
||||||
|
const filteredGlobalPayload = filterSessionsByDirectory(globalPayload, preferredDirectory);
|
||||||
|
payload = filteredGlobalPayload.length > 0 ? filteredGlobalPayload : globalPayload;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
payload = await listSessions(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const seen = new Set();
|
||||||
|
const rows = [];
|
||||||
|
|
||||||
|
for (const item of payload) {
|
||||||
|
if (!item || typeof item !== 'object') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = typeof item.id === 'string' ? item.id.trim().slice(0, 160) : '';
|
||||||
|
if (!id || seen.has(id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
seen.add(id);
|
||||||
|
const title = normalizeShortcutTitle(item.title, `Session ${rows.length + 1}`);
|
||||||
|
const updatedAt = sessionUpdatedAt(item);
|
||||||
|
|
||||||
|
rows.push({ id, title, updatedAt });
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.sort((a, b) => b.updatedAt - a.updatedAt);
|
||||||
|
|
||||||
|
const shortcuts = rows.slice(0, 3).map((session) => ({
|
||||||
|
name: session.title,
|
||||||
|
short_name: session.title.length > 32 ? session.title.slice(0, 32) : session.title,
|
||||||
|
description: 'Open recent session',
|
||||||
|
url: `/?session=${encodeURIComponent(session.id)}`,
|
||||||
|
icons: [{ src: '/pwa-192.png', sizes: '192x192', type: 'image/png' }],
|
||||||
|
}));
|
||||||
|
|
||||||
|
recentPwaSessionsCache.set(cacheKey, { at: now, data: shortcuts });
|
||||||
|
return shortcuts;
|
||||||
|
} catch {
|
||||||
|
recentPwaSessionsCache.set(cacheKey, { at: now, data: [] });
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
app.get('/manifest.webmanifest', async (req, res) => {
|
||||||
|
const hasQueryOverride =
|
||||||
|
typeof req.query?.pwa_name === 'string'
|
||||||
|
|| typeof req.query?.app_name === 'string'
|
||||||
|
|| typeof req.query?.appName === 'string';
|
||||||
|
|
||||||
|
let queryValueRaw = '';
|
||||||
|
if (typeof req.query?.pwa_name === 'string') {
|
||||||
|
queryValueRaw = req.query.pwa_name;
|
||||||
|
} else if (typeof req.query?.app_name === 'string') {
|
||||||
|
queryValueRaw = req.query.app_name;
|
||||||
|
} else if (typeof req.query?.appName === 'string') {
|
||||||
|
queryValueRaw = req.query.appName;
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryOverrideName = normalizePwaAppName(queryValueRaw, '');
|
||||||
|
|
||||||
|
let storedName = '';
|
||||||
|
try {
|
||||||
|
const settings = await readSettingsFromDiskMigrated();
|
||||||
|
storedName = normalizePwaAppName(settings?.pwaAppName, '');
|
||||||
|
} catch {
|
||||||
|
storedName = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const appName = hasQueryOverride
|
||||||
|
? (queryOverrideName || DEFAULT_PWA_APP_NAME)
|
||||||
|
: (storedName || DEFAULT_PWA_APP_NAME);
|
||||||
|
|
||||||
|
const shortName = appName.length > 30 ? appName.slice(0, 30) : appName;
|
||||||
|
const recentSessionShortcuts = await getRecentPwaSessionShortcuts(req);
|
||||||
|
|
||||||
|
const manifest = {
|
||||||
|
name: appName,
|
||||||
|
short_name: shortName,
|
||||||
|
description: 'Web interface companion for OpenCode AI coding agent',
|
||||||
|
id: '/',
|
||||||
|
start_url: '/',
|
||||||
|
scope: '/',
|
||||||
|
display: 'standalone',
|
||||||
|
background_color: '#151313',
|
||||||
|
theme_color: '#edb449',
|
||||||
|
orientation: 'any',
|
||||||
|
icons: [
|
||||||
|
{ src: '/pwa-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
|
||||||
|
{ src: '/pwa-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
|
||||||
|
{ src: '/pwa-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'any maskable' },
|
||||||
|
{ src: '/pwa-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'any maskable' },
|
||||||
|
{ src: '/apple-touch-icon-180x180.png', sizes: '180x180', type: 'image/png', purpose: 'any' },
|
||||||
|
{ src: '/apple-touch-icon-152x152.png', sizes: '152x152', type: 'image/png', purpose: 'any' },
|
||||||
|
{ src: '/favicon-32.png', sizes: '32x32', type: 'image/png' },
|
||||||
|
{ src: '/favicon-16.png', sizes: '16x16', type: 'image/png' },
|
||||||
|
],
|
||||||
|
shortcuts: [
|
||||||
|
{
|
||||||
|
name: 'Appearance Settings',
|
||||||
|
short_name: 'Settings',
|
||||||
|
description: 'Open appearance settings',
|
||||||
|
url: '/?settings=appearance',
|
||||||
|
icons: [{ src: '/pwa-192.png', sizes: '192x192', type: 'image/png' }],
|
||||||
|
},
|
||||||
|
...recentSessionShortcuts,
|
||||||
|
],
|
||||||
|
categories: ['developer', 'tools', 'productivity'],
|
||||||
|
lang: 'en',
|
||||||
|
};
|
||||||
|
|
||||||
|
res.setHeader('Cache-Control', 'no-store, must-revalidate');
|
||||||
|
res.type('application/manifest+json');
|
||||||
|
res.send(JSON.stringify(manifest));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get(/^(?!\/api|.*\.(js|css|svg|png|jpg|jpeg|gif|ico|woff|woff2|ttf|eot|map)).*$/, (req, res) => {
|
app.get(/^(?!\/api|.*\.(js|css|svg|png|jpg|jpeg|gif|ico|woff|woff2|ttf|eot|map)).*$/, (req, res) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user