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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

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"
}
]
}
145 changes: 145 additions & 0 deletions chat-ui/public/sw.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
// 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: let the browser handle normally (no SW interception)
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"
) {
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