Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
6 changes: 6 additions & 0 deletions chat-ui/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@
<meta name="color-scheme" content="light dark" />
<title>Phantom</title>
<link rel="icon" href="/chat/favicon.svg" type="image/svg+xml" />
<link rel="manifest" href="/chat/manifest.webmanifest" />
<link rel="apple-touch-icon" href="/chat/favicon.svg" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="Phantom" />
<meta name="theme-color" content="#4850c4" />
<link
rel="preload"
href="/chat/fonts/inter-var-latin.woff2"
Expand Down
4 changes: 3 additions & 1 deletion chat-ui/public/manifest.webmanifest
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"name": "Phantom",
"short_name": "Phantom",
"description": "Phantom AI co-worker",
"id": "/chat/",
"start_url": "/chat/",
"scope": "/chat/",
"display": "standalone",
Expand All @@ -11,7 +12,8 @@
{
"src": "/chat/favicon.svg",
"sizes": "any",
"type": "image/svg+xml"
"type": "image/svg+xml",
"purpose": "any"
}
]
}
146 changes: 146 additions & 0 deletions chat-ui/public/sw.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
// Phantom chat Service Worker
// Scope: /chat/
// Handles offline caching for static assets and push notification display.
// Never intercepts /ui/* paths or SSE streams.

const VERSION = "0.1.0";
const SHELL_CACHE = "phantom-chat-shell-" + VERSION;

self.addEventListener("install", function (event) {
self.skipWaiting();
});

self.addEventListener("activate", function (event) {
event.waitUntil(
caches
.keys()
.then(function (keys) {
return Promise.all(
keys
.filter(function (k) {
return k.startsWith("phantom-chat-") && k !== SHELL_CACHE;
})
.map(function (k) {
return caches.delete(k);
}),
);
})
.then(function () {
return self.clients.claim();
}),
);
});

self.addEventListener("fetch", function (event) {
var url = new URL(event.request.url);

// Never intercept requests outside chat scope
if (!url.pathname.startsWith("/chat/")) return;

// Never intercept the SW script itself
if (url.pathname === "/chat/sw.js") return;

// Never intercept SSE stream
if (url.pathname === "/chat/stream") return;

// API calls: network-only, no cache
if (
url.pathname.startsWith("/chat/push/") ||
url.pathname === "/chat/bootstrap" ||
url.pathname === "/chat/sessions" ||
url.pathname.startsWith("/chat/sessions/") ||
url.pathname.startsWith("/chat/events/") ||
url.pathname === "/chat/focus"
) {
event.respondWith(fetch(event.request));
return;
}

// Static assets: cache-first with background revalidation
if (
url.pathname.startsWith("/chat/assets/") ||
url.pathname.startsWith("/chat/fonts/")
) {
event.respondWith(
caches.match(event.request).then(function (cached) {
if (cached) {
fetch(event.request)
.then(function (res) {
if (res.ok) {
caches.open(SHELL_CACHE).then(function (c) {
c.put(event.request, res);
});
}
})
.catch(function () {});
return cached;
}
return fetch(event.request).then(function (res) {
if (res.ok && res.type === "basic") {
var clone = res.clone();
caches.open(SHELL_CACHE).then(function (c) {
c.put(event.request, clone);
});
}
return res;
});
}),
);
return;
}
});

self.addEventListener("push", function (event) {
var data = {};
if (event.data) {
try {
data = event.data.json();
} catch (e) {
data = { body: event.data.text() };
}
}
var title = data.title || "Phantom";
var options = {
body: data.body || "",
icon: "/chat/favicon.svg",
badge: "/chat/favicon.svg",
tag: data.tag,
data: data.data || {},
requireInteraction: data.requireInteraction || false,
silent: data.silent || false,
};
event.waitUntil(self.registration.showNotification(title, options));
});

self.addEventListener("notificationclick", function (event) {
event.notification.close();
var target = (event.notification.data && event.notification.data.url) || "/chat/";
event.waitUntil(
self.clients
.matchAll({ type: "window", includeUncontrolled: true })
.then(function (clientsList) {
for (var i = 0; i < clientsList.length; i++) {
var client = clientsList[i];
if (
client.url.startsWith(self.location.origin) &&
"focus" in client
) {
client.focus();
client.postMessage({
type: "notification-click",
url: target,
data: event.notification.data,
});
return;
}
}
return self.clients.openWindow(target);
}),
);
});

self.addEventListener("message", function (event) {
if (event.data && event.data.type === "SKIP_WAITING") {
self.skipWaiting();
}
});
79 changes: 79 additions & 0 deletions chat-ui/src/components/ios-install-banner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// iOS Safari install banner. Detects iOS Safari not-standalone mode and
// shows guidance to use Share > Add to Home Screen. Dismissible with
// localStorage persistence (7 days).

import { useCallback, useState } from "react";
import { Button } from "@/ui/button";

const DISMISS_KEY = "phantom_ios_install_dismissed_at";
const DISMISS_DURATION_MS = 7 * 24 * 60 * 60 * 1000; // 7 days

function isIosSafari(): boolean {
if (typeof navigator === "undefined") return false;
const ua = navigator.userAgent;
const isIos =
/iPad|iPhone|iPod/.test(ua) ||
(ua.includes("Mac") && "ontouchend" in document);
const isSafari = /Safari/.test(ua) && !/CriOS|FxiOS|EdgiOS/.test(ua);
return isIos && isSafari;
}

function isStandalone(): boolean {
if (typeof window === "undefined") return false;
return (
window.matchMedia("(display-mode: standalone)").matches ||
("standalone" in navigator && (navigator as Record<string, unknown>).standalone === true)
);
}

function isDismissed(): boolean {
try {
const raw = localStorage.getItem(DISMISS_KEY);
if (!raw) return false;
const dismissedAt = Number(raw);
return Date.now() - dismissedAt < DISMISS_DURATION_MS;
} catch {
return false;
}
}

export function IosInstallBanner() {
const [dismissed, setDismissed] = useState(isDismissed);

const handleDismiss = useCallback(() => {
try {
localStorage.setItem(DISMISS_KEY, String(Date.now()));
} catch {
// localStorage unavailable
}
setDismissed(true);
}, []);

if (!isIosSafari() || isStandalone() || dismissed) {
return null;
}

return (
<div className="mx-auto mb-3 flex w-full max-w-2xl items-center gap-3 rounded-lg border border-border bg-card px-4 py-3">
<p className="flex-1 text-sm text-muted-foreground">
Install Phantom to your home screen for notifications. Tap{" "}
<svg
className="inline-block h-4 w-4 align-text-bottom"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M12 5v14M5 12l7-7 7 7" />
<rect x="4" y="17" width="16" height="2" rx="1" />
</svg>{" "}
Share, then Add to Home Screen.
</p>
<Button size="sm" variant="ghost" onClick={handleDismiss}>
Dismiss
</Button>
</div>
);
}
80 changes: 80 additions & 0 deletions chat-ui/src/components/notification-banner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// Soft permission banner shown after the user sends their first message.
// "Want a ping when your task is done?" with Enable and Dismiss buttons.
// Dismiss hides for 24 hours via localStorage.

import { useCallback, useState } from "react";
import { useNotifications } from "@/hooks/use-notifications";
import { Button } from "@/ui/button";

const DISMISS_KEY = "phantom_notification_banner_dismissed_at";
const DISMISS_DURATION_MS = 24 * 60 * 60 * 1000; // 24 hours

function isDismissed(): boolean {
try {
const raw = localStorage.getItem(DISMISS_KEY);
if (!raw) return false;
const dismissedAt = Number(raw);
return Date.now() - dismissedAt < DISMISS_DURATION_MS;
} catch {
return false;
}
}

export function NotificationBanner({
visible,
}: {
visible: boolean;
}) {
const { permission, subscribed, subscribe } = useNotifications();
const [dismissed, setDismissed] = useState(isDismissed);
const [enabling, setEnabling] = useState(false);

const handleEnable = useCallback(async () => {
setEnabling(true);
try {
await subscribe();
} finally {
setEnabling(false);
}
}, [subscribe]);

const handleDismiss = useCallback(() => {
try {
localStorage.setItem(DISMISS_KEY, String(Date.now()));
} catch {
// localStorage unavailable
}
setDismissed(true);
}, []);

// Hide if: not visible yet, already subscribed, already dismissed,
// permission denied, or browser doesn't support it
if (
!visible ||
subscribed ||
dismissed ||
permission === "denied" ||
permission === "unsupported"
) {
return null;
}

return (
<div className="mx-auto mb-3 flex w-full max-w-2xl items-center gap-3 rounded-lg border border-border bg-card px-4 py-3">
<p className="flex-1 text-sm text-muted-foreground">
Want a ping when your task is done?
</p>
<Button
size="sm"
variant="default"
onClick={handleEnable}
disabled={enabling}
>
{enabling ? "Enabling..." : "Enable"}
</Button>
<Button size="sm" variant="ghost" onClick={handleDismiss}>
Not now
</Button>
</div>
);
}
Loading
Loading