feat: PWA install prompt, brand-colored channel icons, image load fix, semantic connection indicator
- Fix image display bug: remove loading='lazy' + use opacity instead of hidden (#66) - PWA: add id/scope to manifest, fix SW cache name, add install prompt hook + button (#64) - Colorize channel icons with brand colors (Discord blurple, Telegram blue, etc.) (#68) - Use semantic green for connection indicator instead of theme accent (#70) - Replace Sparkles icon with PinchChat logo in sidebar (#65) - Replace decorative dots in sidebar footer with GitHub link (#71)
This commit is contained in:
60
FEEDBACK.md
60
FEEDBACK.md
@@ -719,3 +719,63 @@
|
||||
- **Priority:** medium
|
||||
- **Status:** open
|
||||
- **Description:** Context compaction button — Add a button in the PinchChat UI to trigger OpenClaw's context summarize/compaction. When a session's token usage is high (e.g. near the limit), the user can click to compact the conversation history, summarizing older messages to free up context window space. OpenClaw should expose an API/tool for this. (Feedback from Bardak)
|
||||
|
||||
## Item #64
|
||||
- **Date:** 2026-02-14
|
||||
- **Priority:** high
|
||||
- **Status:** open
|
||||
- **Source:** Josh (Bardak)
|
||||
- **Description:** PWA not working — no install button appears on mobile or desktop. The app should be installable as a Progressive Web App. Check manifest.json, service worker registration, and install prompt handling. Test on both Chrome mobile and desktop.
|
||||
|
||||
## Item #65
|
||||
- **Date:** 2026-02-14
|
||||
- **Priority:** medium
|
||||
- **Status:** open
|
||||
- **Source:** Josh (Bardak)
|
||||
- **Description:** Replace the star icon (⭐) next to "PinchChat" and "Sessions" in the sidebar/navbar. It looks cheap. Replace with something better — PinchChat logo, lobster emoji (🦞), or whatever looks cool and fits the branding. Make it feel intentional, not placeholder.
|
||||
|
||||
## Item #66
|
||||
- **Date:** 2026-02-14
|
||||
- **Priority:** high
|
||||
- **Status:** open
|
||||
- **Source:** Josh (Bardak)
|
||||
- **Description:** Image display bug — when the assistant sends an image, it initially shows as an empty square container (wrong aspect ratio, no image visible). Clicking it opens the preview modal correctly and shows the image. After closing the preview, the image then renders correctly in the message bubble with the right aspect ratio. The image should render correctly on first load without requiring user interaction. Likely a lazy-loading or onload sizing issue.
|
||||
|
||||
## Item #67 (DUPLICATE — see Item #63)
|
||||
- **Date:** 2026-02-14
|
||||
- **Note:** Already tracked as Item #63. Context compaction button.
|
||||
|
||||
## Item #68
|
||||
- **Date:** 2026-02-14
|
||||
- **Priority:** medium
|
||||
- **Status:** open
|
||||
- **Source:** Josh (Bardak)
|
||||
- **Description:** Colorize channel/source icons in the session list and chat — use each service's brand colors for visual identification. Discord = blurple (#5865F2), Telegram = blue (#26A5E4), Cron = amber/orange, TeamSpeak = blue (#2580C3), Signal = blue (#3A76F0), webchat = green, bardak-bot = custom, etc. Currently they're all the same color and hard to distinguish at a glance.
|
||||
|
||||
## Item #69
|
||||
- **Date:** 2026-02-14
|
||||
- **Priority:** medium
|
||||
- **Status:** open
|
||||
- **Source:** Josh (Bardak)
|
||||
- **Description:** Display the agent/assistant name somewhere in the UI (header, sidebar, or chat). The name is available in OpenClaw config at `ui.assistant.name` (e.g. "Marlbot"). Currently there's no indication of who the agent is — should be visible, especially for multi-agent setups. Could also show the avatar if `ui.assistant.avatar` is set. **Important:** these fields are optional and most users won't set them — always have a sensible fallback (e.g. "Assistant" + default robot icon) when `ui.assistant.name` / `ui.assistant.avatar` are absent.
|
||||
|
||||
## Item #70
|
||||
- **Date:** 2026-02-14
|
||||
- **Priority:** medium
|
||||
- **Status:** open
|
||||
- **Source:** Josh (Bardak)
|
||||
- **Description:** WebSocket connection indicator uses primary/accent color — this is problematic because when the theme accent is red, the "connected" dot looks like an error/disconnected state. Use fixed semantic colors instead: green (#22c55e) for connected, red for disconnected, amber for reconnecting. Never tie connection status indicator to the theme accent color.
|
||||
|
||||
## Item #71
|
||||
- **Date:** 2026-02-14
|
||||
- **Priority:** low
|
||||
- **Status:** open
|
||||
- **Source:** Josh (Bardak)
|
||||
- **Description:** The three-dot menu (⋯) at the bottom of the sidebar next to the version number feels useless. Remove it or replace with something actually useful (e.g. settings shortcut, theme picker, link to GitHub repo, etc.).
|
||||
|
||||
## Item #72
|
||||
- **Date:** 2026-02-14
|
||||
- **Priority:** high
|
||||
- **Status:** open
|
||||
- **Source:** Josh (Bardak)
|
||||
- **Description:** After OpenClaw compaction, all previous messages disappear from the chat UI. This is a bad UX — the user loses their message history visually even though the conversation continues. Ideally PinchChat should keep a local cache/history of previous messages (IndexedDB or localStorage) so the user can still scroll back and see/copy-paste old messages even after compaction. The compaction boundary could be shown with a visual separator ("— context compacted —") but old messages should remain visible above it. This is critical for usability — users need to be able to reference what they said earlier.
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
{
|
||||
"id": "/",
|
||||
"name": "PinchChat",
|
||||
"short_name": "PinchChat",
|
||||
"description": "A sleek, dark-themed webchat UI for OpenClaw — monitor sessions, stream responses, and inspect tool calls in real-time.",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#1e1e24",
|
||||
"theme_color": "#1e1e24",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// PinchChat Service Worker — cache static assets for offline/instant load
|
||||
const CACHE_NAME = 'pinchchat-__SW_VERSION__';
|
||||
const CACHE_NAME = 'pinchchat-v1';
|
||||
|
||||
// Cache static assets on install
|
||||
self.addEventListener('install', (event) => {
|
||||
|
||||
@@ -81,7 +81,7 @@ export function Header({ status, sessionKey, onToggleSidebar, activeSessionData,
|
||||
<span className="hidden sm:contents"><LanguageSelector /></span>
|
||||
{status === 'connected' ? (
|
||||
<div className="flex items-center gap-2 rounded-2xl border border-pc-border bg-pc-elevated/30 px-3 py-1.5">
|
||||
<span className="w-2 h-2 rounded-full bg-[var(--pc-accent)] shadow-[0_0_12px_var(--pc-accent-dim)]" />
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-400 shadow-[0_0_12px_rgba(52,211,153,0.4)]" />
|
||||
<span className="text-xs text-pc-text hidden sm:inline">{t('header.connected')}</span>
|
||||
</div>
|
||||
) : status === 'connecting' ? (
|
||||
|
||||
@@ -64,17 +64,18 @@ export function ImageBlock({ src, alt }: ImageBlockProps) {
|
||||
aria-label={`View ${alt || 'image'} full size`}
|
||||
className="block rounded-xl border border-pc-border cursor-pointer hover:brightness-110 transition-all focus:outline-none focus:ring-2 focus:ring-[var(--pc-accent-dim)]"
|
||||
>
|
||||
{loading && (
|
||||
<div className="w-48 h-32 rounded-xl bg-pc-elevated/50 animate-pulse" />
|
||||
)}
|
||||
<img
|
||||
src={src}
|
||||
alt={alt || 'Image'}
|
||||
className={`max-w-full max-h-80 rounded-xl${loading ? ' hidden' : ''}`}
|
||||
loading="lazy"
|
||||
onLoad={() => setLoading(false)}
|
||||
onError={() => { setLoading(false); setError(true); }}
|
||||
/>
|
||||
<div className="relative">
|
||||
{loading && (
|
||||
<div className="w-48 h-32 rounded-xl bg-pc-elevated/50 animate-pulse" />
|
||||
)}
|
||||
<img
|
||||
src={src}
|
||||
alt={alt || 'Image'}
|
||||
className={`max-w-full max-h-80 rounded-xl transition-opacity duration-200${loading ? ' absolute top-0 left-0 opacity-0 pointer-events-none' : ' opacity-100'}`}
|
||||
onLoad={() => setLoading(false)}
|
||||
onError={() => { setLoading(false); setError(true); }}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
{lightbox && <Lightbox src={src} alt={alt} onClose={() => setLightbox(false)} />}
|
||||
|
||||
@@ -46,44 +46,63 @@ function SlackIcon({ size = 15, className = '' }: { size?: number; className?: s
|
||||
* Returns the appropriate icon for a session based on its channel or kind.
|
||||
* Detects cron sessions from the session key pattern.
|
||||
*/
|
||||
export function SessionIcon({ session, isActive, isCurrentSession }: {
|
||||
export function SessionIcon({ session, isCurrentSession }: {
|
||||
session: Session;
|
||||
isActive?: boolean;
|
||||
isActive?: boolean; // kept for API compat, brand colors used instead
|
||||
isCurrentSession?: boolean;
|
||||
}) {
|
||||
const size = 15;
|
||||
const baseClass = isCurrentSession ? 'text-pc-accent-light/70' : isActive ? 'text-violet-400/70' : '';
|
||||
// Brand colors for each channel
|
||||
const brandColor = (() => {
|
||||
const isCron = session.key.includes(':cron:');
|
||||
if (isCron) return 'text-amber-400/80';
|
||||
const isSubAgent = session.key.includes(':spawn:') || session.key.includes(':sub:');
|
||||
if (isSubAgent) return 'text-violet-400/80';
|
||||
const ch = session.channel?.toLowerCase();
|
||||
switch (ch) {
|
||||
case 'discord': return 'text-[#5865F2]';
|
||||
case 'telegram': return 'text-[#26A5E4]';
|
||||
case 'signal': return 'text-[#3A76F0]';
|
||||
case 'whatsapp': return 'text-[#25D366]';
|
||||
case 'slack': return 'text-[#E01E5A]';
|
||||
case 'webchat': return 'text-emerald-400/80';
|
||||
case 'teamspeak': return 'text-[#2580C3]';
|
||||
default: return 'text-pc-text-secondary';
|
||||
}
|
||||
})();
|
||||
|
||||
const colorClass = isCurrentSession ? 'text-pc-accent-light' : brandColor;
|
||||
|
||||
// Detect cron sessions from key pattern
|
||||
const isCron = session.key.includes(':cron:');
|
||||
if (isCron) {
|
||||
return <Clock size={size} className={baseClass} />;
|
||||
return <Clock size={size} className={colorClass} />;
|
||||
}
|
||||
|
||||
// Detect sub-agent / spawned sessions
|
||||
const isSubAgent = session.key.includes(':spawn:') || session.key.includes(':sub:');
|
||||
if (isSubAgent) {
|
||||
return <Bot size={size} className={baseClass} />;
|
||||
return <Bot size={size} className={colorClass} />;
|
||||
}
|
||||
|
||||
const channel = session.channel?.toLowerCase();
|
||||
|
||||
switch (channel) {
|
||||
case 'discord':
|
||||
return <DiscordIcon size={size} className={baseClass} />;
|
||||
return <DiscordIcon size={size} className={colorClass} />;
|
||||
case 'telegram':
|
||||
return <TelegramIcon size={size} className={baseClass} />;
|
||||
return <TelegramIcon size={size} className={colorClass} />;
|
||||
case 'signal':
|
||||
return <SignalIcon size={size} className={baseClass} />;
|
||||
return <SignalIcon size={size} className={colorClass} />;
|
||||
case 'whatsapp':
|
||||
return <WhatsAppIcon size={size} className={baseClass} />;
|
||||
return <WhatsAppIcon size={size} className={colorClass} />;
|
||||
case 'slack':
|
||||
return <SlackIcon size={size} className={baseClass} />;
|
||||
return <SlackIcon size={size} className={colorClass} />;
|
||||
case 'webchat':
|
||||
return <Globe size={size} className={baseClass} />;
|
||||
return <Globe size={size} className={colorClass} />;
|
||||
case 'teamspeak':
|
||||
return <MessageSquare size={size} className={baseClass} />;
|
||||
return <MessageSquare size={size} className={colorClass} />;
|
||||
default:
|
||||
return <MessageSquare size={size} className={baseClass} />;
|
||||
return <MessageSquare size={size} className={colorClass} />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useState, useMemo, useRef, useEffect, useCallback } from 'react';
|
||||
import { X, Sparkles, Search, Pin, Trash2, Columns2, Clock, Bot, MessageSquare, Globe, Zap, ArrowUpCircle } from 'lucide-react';
|
||||
import { X, Search, Pin, Trash2, Columns2, Clock, Bot, MessageSquare, Globe, Zap, ArrowUpCircle, Download } from 'lucide-react';
|
||||
import type { Session } from '../types';
|
||||
import { useT } from '../hooks/useLocale';
|
||||
import { SessionIcon } from './SessionIcon';
|
||||
import { sessionDisplayName } from '../lib/sessionName';
|
||||
import { relativeTime } from '../lib/relativeTime';
|
||||
import { useUpdateCheck } from '../hooks/useUpdateCheck';
|
||||
import { usePwaInstall } from '../hooks/usePwaInstall';
|
||||
|
||||
function VersionBadge() {
|
||||
const update = useUpdateCheck(__APP_VERSION__);
|
||||
@@ -31,6 +32,34 @@ function VersionBadge() {
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarFooter() {
|
||||
const pwa = usePwaInstall();
|
||||
return (
|
||||
<div className="px-4 py-3 border-t border-pc-border flex items-center justify-center gap-3">
|
||||
{pwa.canInstall && (
|
||||
<button
|
||||
onClick={pwa.install}
|
||||
className="inline-flex items-center gap-1 text-[10px] text-pc-accent-light hover:text-[var(--pc-accent)] transition-colors"
|
||||
title="Install app"
|
||||
>
|
||||
<Download size={11} />
|
||||
<span>Install</span>
|
||||
</button>
|
||||
)}
|
||||
<a
|
||||
href="https://github.com/MarlBurroW/pinchchat"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-pc-text-faint hover:text-pc-text-secondary transition-colors"
|
||||
title="GitHub"
|
||||
>
|
||||
<Globe size={11} />
|
||||
</a>
|
||||
<VersionBadge />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const PINNED_KEY = 'pinchchat-pinned-sessions';
|
||||
const WIDTH_KEY = 'pinchchat-sidebar-width';
|
||||
const ORDER_KEY = 'pinchchat-session-order';
|
||||
@@ -258,8 +287,8 @@ export function Sidebar({ sessions, activeSession, onSwitch, onDelete, onSplit,
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<div className="absolute -inset-1.5 rounded-xl bg-gradient-to-r from-cyan-400/15 to-violet-500/15 blur-lg" />
|
||||
<div className="relative flex h-8 w-8 items-center justify-center rounded-xl border border-pc-border bg-pc-elevated/50">
|
||||
<Sparkles className="h-4 w-4 text-pc-accent-light" />
|
||||
<div className="relative flex h-8 w-8 items-center justify-center rounded-xl overflow-hidden">
|
||||
<img src="/logo.png" alt="PinchChat" className="h-8 w-8 object-contain" />
|
||||
</div>
|
||||
</div>
|
||||
<span className="font-semibold text-sm text-pc-text tracking-wide">{t('sidebar.title')}</span>
|
||||
@@ -511,12 +540,7 @@ export function Sidebar({ sessions, activeSession, onSwitch, onDelete, onSplit,
|
||||
})}
|
||||
</div>
|
||||
{/* Footer with version */}
|
||||
<div className="px-4 py-3 border-t border-pc-border flex items-center justify-center gap-2">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-violet-300/60 shadow-[0_0_10px_rgba(168,85,247,0.5)]" />
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-[var(--pc-accent-dim)] shadow-[0_0_10px_rgba(34,211,238,0.5)]" />
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-indigo-300/50 shadow-[0_0_10px_rgba(99,102,241,0.4)]" />
|
||||
<VersionBadge />
|
||||
</div>
|
||||
<SidebarFooter />
|
||||
{/* Resize drag handle */}
|
||||
<div
|
||||
onMouseDown={startDrag}
|
||||
|
||||
52
src/hooks/usePwaInstall.ts
Normal file
52
src/hooks/usePwaInstall.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
|
||||
interface BeforeInstallPromptEvent extends Event {
|
||||
prompt(): Promise<void>;
|
||||
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
|
||||
}
|
||||
|
||||
// Check standalone mode outside of React to avoid setState-in-effect
|
||||
const isStandalone = typeof window !== 'undefined' && window.matchMedia('(display-mode: standalone)').matches;
|
||||
|
||||
export function usePwaInstall() {
|
||||
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
|
||||
const [isInstalled, setIsInstalled] = useState(isStandalone);
|
||||
const installedRef = useRef(isStandalone);
|
||||
|
||||
useEffect(() => {
|
||||
if (installedRef.current) return;
|
||||
|
||||
const handler = (e: Event) => {
|
||||
e.preventDefault();
|
||||
setDeferredPrompt(e as BeforeInstallPromptEvent);
|
||||
};
|
||||
|
||||
const installedHandler = () => {
|
||||
installedRef.current = true;
|
||||
setIsInstalled(true);
|
||||
setDeferredPrompt(null);
|
||||
};
|
||||
|
||||
window.addEventListener('beforeinstallprompt', handler);
|
||||
window.addEventListener('appinstalled', installedHandler);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('beforeinstallprompt', handler);
|
||||
window.removeEventListener('appinstalled', installedHandler);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const install = useCallback(async () => {
|
||||
if (!deferredPrompt) return false;
|
||||
await deferredPrompt.prompt();
|
||||
const { outcome } = await deferredPrompt.userChoice;
|
||||
setDeferredPrompt(null);
|
||||
return outcome === 'accepted';
|
||||
}, [deferredPrompt]);
|
||||
|
||||
return {
|
||||
canInstall: !!deferredPrompt && !isInstalled,
|
||||
isInstalled,
|
||||
install,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user