Skip to content

Commit b041362

Browse files
authored
Add floating chat widget for mobile shell (#327)
- Persist widget state and last active thread - Add minimized bubble, expanded panel, and swipe-to-minimize
1 parent 9defd8b commit b041362

10 files changed

Lines changed: 445 additions & 4 deletions

File tree

apps/web/src/chatWidgetStore.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { create } from "zustand";
2+
3+
export type ChatWidgetMode = "minimized" | "expanded";
4+
5+
interface PersistedWidgetState {
6+
mode: ChatWidgetMode;
7+
lastThreadId: string | null;
8+
}
9+
10+
interface ChatWidgetStore extends PersistedWidgetState {
11+
expand: () => void;
12+
minimize: () => void;
13+
setLastThreadId: (id: string) => void;
14+
}
15+
16+
const WIDGET_STORAGE_KEY = "okcode:chat-widget:v1";
17+
18+
function createEmptyState(): PersistedWidgetState {
19+
return {
20+
mode: "expanded",
21+
lastThreadId: null,
22+
};
23+
}
24+
25+
function readPersistedState(): PersistedWidgetState {
26+
if (typeof window === "undefined") {
27+
return createEmptyState();
28+
}
29+
30+
try {
31+
const raw = window.localStorage.getItem(WIDGET_STORAGE_KEY);
32+
if (!raw) {
33+
return createEmptyState();
34+
}
35+
36+
const parsed = JSON.parse(raw) as Partial<PersistedWidgetState>;
37+
return {
38+
mode: parsed.mode === "minimized" || parsed.mode === "expanded" ? parsed.mode : "expanded",
39+
lastThreadId:
40+
typeof parsed.lastThreadId === "string" && parsed.lastThreadId.length > 0
41+
? parsed.lastThreadId
42+
: null,
43+
};
44+
} catch {
45+
return createEmptyState();
46+
}
47+
}
48+
49+
function persistState(state: PersistedWidgetState): void {
50+
if (typeof window === "undefined") {
51+
return;
52+
}
53+
54+
try {
55+
window.localStorage.setItem(
56+
WIDGET_STORAGE_KEY,
57+
JSON.stringify({
58+
mode: state.mode,
59+
lastThreadId: state.lastThreadId,
60+
} satisfies PersistedWidgetState),
61+
);
62+
} catch {
63+
// Ignore storage errors.
64+
}
65+
}
66+
67+
function snapshotState(state: ChatWidgetStore): PersistedWidgetState {
68+
return {
69+
mode: state.mode,
70+
lastThreadId: state.lastThreadId,
71+
};
72+
}
73+
74+
const initialState = readPersistedState();
75+
76+
export const useChatWidgetStore = create<ChatWidgetStore>((set, get) => ({
77+
...initialState,
78+
79+
expand: () => {
80+
set(() => {
81+
const next = { ...snapshotState(get()), mode: "expanded" as const };
82+
persistState(next);
83+
return { mode: "expanded" };
84+
});
85+
},
86+
87+
minimize: () => {
88+
set(() => {
89+
const next = { ...snapshotState(get()), mode: "minimized" as const };
90+
persistState(next);
91+
return { mode: "minimized" };
92+
});
93+
},
94+
95+
setLastThreadId: (id: string) => {
96+
set(() => {
97+
const next = { ...snapshotState(get()), lastThreadId: id };
98+
persistState(next);
99+
return { lastThreadId: id };
100+
});
101+
},
102+
}));

apps/web/src/components/ChatView.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,7 @@ const INTERACTION_MODE_CYCLE: readonly ProviderInteractionMode[] = ["chat", "cod
365365

366366
interface ChatViewProps {
367367
threadId: ThreadId;
368+
onMinimize?: (() => void) | undefined;
368369
}
369370

370371
interface RunProjectScriptOptions {
@@ -375,7 +376,7 @@ interface RunProjectScriptOptions {
375376
rememberAsLastInvoked?: boolean;
376377
}
377378

378-
export default function ChatView({ threadId }: ChatViewProps) {
379+
export default function ChatView({ threadId, onMinimize }: ChatViewProps) {
379380
const clientMode = useClientMode();
380381
const transportState = useTransportState();
381382
const threads = useStore((store) => store.threads);
@@ -4849,6 +4850,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
48494850
onToggleDiffViewer={handleToggleDiffViewer}
48504851
onTogglePreview={() => activeProjectId && togglePreviewOpen(activeProjectId)}
48514852
onTogglePreviewLayout={() => activeProjectId && togglePreviewLayout(activeProjectId)}
4853+
onMinimize={onMinimize}
48524854
/>
48534855
</header>
48544856

apps/web/src/components/chat/ChatHeader.tsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
type ResolvedKeybindingsConfig,
66
} from "@okcode/contracts";
77
import { useQuery } from "@tanstack/react-query";
8-
import { ExternalLinkIcon, GitPullRequestIcon } from "lucide-react";
8+
import { ChevronDownIcon, ExternalLinkIcon, GitPullRequestIcon } from "lucide-react";
99
import { memo, useCallback, useEffect } from "react";
1010
import type { ProjectScriptDraft } from "~/projectScriptDefaults";
1111
import GitActionsControl from "../GitActionsControl";
@@ -59,6 +59,7 @@ interface ChatHeaderProps {
5959
onToggleDiffViewer: () => void;
6060
onTogglePreview: () => void;
6161
onTogglePreviewLayout: () => void;
62+
onMinimize?: (() => void) | undefined;
6263
}
6364

6465
export const ChatHeader = memo(function ChatHeader({
@@ -94,6 +95,7 @@ export const ChatHeader = memo(function ChatHeader({
9495
onToggleDiffViewer,
9596
onTogglePreview,
9697
onTogglePreviewLayout: _onTogglePreviewLayout,
98+
onMinimize,
9799
}: ChatHeaderProps) {
98100
const isMobileCompanion = clientMode === "mobile";
99101
const projectColor = useProjectColor(activeProjectId);
@@ -141,6 +143,18 @@ export const ChatHeader = memo(function ChatHeader({
141143
{/* Left: Identity — thread title + project context */}
142144
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden sm:gap-3">
143145
<SidebarTrigger className="size-7 shrink-0" />
146+
{onMinimize && (
147+
<Button
148+
type="button"
149+
size="xs"
150+
variant="ghost"
151+
aria-label="Minimize chat"
152+
className="size-7 shrink-0"
153+
onClick={onMinimize}
154+
>
155+
<ChevronDownIcon className="size-4" />
156+
</Button>
157+
)}
144158
<EditableThreadTitle
145159
title={activeThreadTitle}
146160
isEditing={isEditingTitle}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { memo, useCallback } from "react";
2+
import { useNavigate } from "@tanstack/react-router";
3+
import { MessageCircleIcon } from "lucide-react";
4+
5+
import { useChatWidgetStore } from "../../chatWidgetStore";
6+
import { useMobileConnectionState } from "../../hooks/useMobileConnectionState";
7+
import { useChatWidgetStatus, type ChatWidgetTone } from "../../hooks/useChatWidgetStatus";
8+
9+
const TONE_DOT_CLASSES: Record<ChatWidgetTone, string> = {
10+
idle: "bg-emerald-500 dark:bg-emerald-400",
11+
running: "bg-blue-500 dark:bg-blue-400 animate-pulse",
12+
attention: "bg-amber-500 dark:bg-amber-400 animate-pulse",
13+
error: "bg-red-500 dark:bg-red-400",
14+
};
15+
16+
/**
17+
* The minimized floating pill/bubble for the chat widget.
18+
* Shows connection status, thread title, and activity status.
19+
* Tapping expands back to the full chat.
20+
*/
21+
export const ChatWidgetBubble = memo(function ChatWidgetBubble() {
22+
const expand = useChatWidgetStore((s) => s.expand);
23+
const lastThreadId = useChatWidgetStore((s) => s.lastThreadId);
24+
const navigate = useNavigate();
25+
const connectionState = useMobileConnectionState();
26+
const { label, tone, threadTitle } = useChatWidgetStatus();
27+
28+
const isDisconnected = connectionState === "disconnected" || connectionState === "reconnecting";
29+
30+
const handleClick = useCallback(() => {
31+
expand();
32+
if (lastThreadId) {
33+
void navigate({ to: "/$threadId", params: { threadId: lastThreadId } });
34+
}
35+
}, [expand, lastThreadId, navigate]);
36+
37+
return (
38+
<button
39+
type="button"
40+
onClick={handleClick}
41+
className="fixed bottom-[max(env(safe-area-inset-bottom,16px),16px)] left-1/2 z-[60] flex -translate-x-1/2 items-center gap-2.5 rounded-full border border-border/60 bg-card/90 px-4 py-2.5 shadow-2xl shadow-black/20 backdrop-blur-md transition-transform duration-200 active:scale-95 dark:border-border/40 dark:bg-card/80 dark:shadow-black/40"
42+
aria-label="Expand chat"
43+
>
44+
{/* Status dot */}
45+
<span className="relative flex size-2.5 shrink-0">
46+
{isDisconnected ? (
47+
<span className="size-2.5 rounded-full bg-red-500 dark:bg-red-400" />
48+
) : (
49+
<span className={`size-2.5 rounded-full ${TONE_DOT_CLASSES[tone]}`} />
50+
)}
51+
</span>
52+
53+
{/* Thread title or app name */}
54+
<span className="max-w-[180px] truncate text-sm font-medium text-foreground">
55+
{threadTitle ?? "OK Code"}
56+
</span>
57+
58+
{/* Activity label */}
59+
<span className="shrink-0 text-xs text-muted-foreground">
60+
{isDisconnected ? "Offline" : label}
61+
</span>
62+
63+
{/* Chat icon */}
64+
<MessageCircleIcon className="size-4 shrink-0 text-muted-foreground" />
65+
</button>
66+
);
67+
});
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { type ReactNode, memo, useCallback, useRef } from "react";
2+
import { cn } from "~/lib/utils";
3+
import { useChatWidgetStore } from "../../chatWidgetStore";
4+
5+
const SWIPE_THRESHOLD_PX = 80;
6+
const SWIPE_VELOCITY_THRESHOLD = 0.3; // px/ms
7+
8+
/**
9+
* The expanded panel container for the chat widget.
10+
* Wraps children (the full chat layout) and provides a swipe-down-to-minimize
11+
* gesture on the top drag handle.
12+
*/
13+
export const ChatWidgetPanel = memo(function ChatWidgetPanel({
14+
children,
15+
expanded,
16+
}: {
17+
children: ReactNode;
18+
expanded: boolean;
19+
}) {
20+
const minimize = useChatWidgetStore((s) => s.minimize);
21+
const panelRef = useRef<HTMLDivElement>(null);
22+
const touchStartRef = useRef<{ y: number; time: number } | null>(null);
23+
24+
const onTouchStart = useCallback((e: React.TouchEvent) => {
25+
const touch = e.touches[0];
26+
if (touch) {
27+
touchStartRef.current = { y: touch.clientY, time: Date.now() };
28+
}
29+
}, []);
30+
31+
const onTouchEnd = useCallback(
32+
(e: React.TouchEvent) => {
33+
const start = touchStartRef.current;
34+
const touch = e.changedTouches[0];
35+
if (!start || !touch) {
36+
touchStartRef.current = null;
37+
return;
38+
}
39+
40+
const deltaY = touch.clientY - start.y;
41+
const elapsed = Date.now() - start.time;
42+
const velocity = elapsed > 0 ? deltaY / elapsed : 0;
43+
44+
if (deltaY > SWIPE_THRESHOLD_PX || velocity > SWIPE_VELOCITY_THRESHOLD) {
45+
minimize();
46+
}
47+
touchStartRef.current = null;
48+
},
49+
[minimize],
50+
);
51+
52+
return (
53+
<div
54+
ref={panelRef}
55+
className={cn(
56+
"fixed inset-0 z-[55] flex flex-col bg-background transition-transform duration-300 ease-[cubic-bezier(0.32,0.72,0,1)] will-change-transform",
57+
expanded ? "translate-y-0" : "translate-y-full pointer-events-none",
58+
)}
59+
>
60+
{/* Swipe-down drag handle */}
61+
<div
62+
className="flex h-6 shrink-0 cursor-grab items-center justify-center touch-none"
63+
onTouchStart={onTouchStart}
64+
onTouchEnd={onTouchEnd}
65+
>
66+
<div className="h-1 w-8 rounded-full bg-muted-foreground/30" />
67+
</div>
68+
69+
{/* Chat content */}
70+
<div className="flex min-h-0 flex-1 flex-col">{children}</div>
71+
</div>
72+
);
73+
});

0 commit comments

Comments
 (0)