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
102 changes: 102 additions & 0 deletions apps/web/src/chatWidgetStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { create } from "zustand";

export type ChatWidgetMode = "minimized" | "expanded";

interface PersistedWidgetState {
mode: ChatWidgetMode;
lastThreadId: string | null;
}

interface ChatWidgetStore extends PersistedWidgetState {
expand: () => void;
minimize: () => void;
setLastThreadId: (id: string) => void;
}

const WIDGET_STORAGE_KEY = "okcode:chat-widget:v1";

function createEmptyState(): PersistedWidgetState {
return {
mode: "expanded",
lastThreadId: null,
};
}

function readPersistedState(): PersistedWidgetState {
if (typeof window === "undefined") {
return createEmptyState();
}

try {
const raw = window.localStorage.getItem(WIDGET_STORAGE_KEY);
if (!raw) {
return createEmptyState();
}

const parsed = JSON.parse(raw) as Partial<PersistedWidgetState>;
return {
mode: parsed.mode === "minimized" || parsed.mode === "expanded" ? parsed.mode : "expanded",
lastThreadId:
typeof parsed.lastThreadId === "string" && parsed.lastThreadId.length > 0
? parsed.lastThreadId
: null,
};
} catch {
return createEmptyState();
}
}

function persistState(state: PersistedWidgetState): void {
if (typeof window === "undefined") {
return;
}

try {
window.localStorage.setItem(
WIDGET_STORAGE_KEY,
JSON.stringify({
mode: state.mode,
lastThreadId: state.lastThreadId,
} satisfies PersistedWidgetState),
);
} catch {
// Ignore storage errors.
}
}

function snapshotState(state: ChatWidgetStore): PersistedWidgetState {
return {
mode: state.mode,
lastThreadId: state.lastThreadId,
};
}

const initialState = readPersistedState();

export const useChatWidgetStore = create<ChatWidgetStore>((set, get) => ({
...initialState,

expand: () => {
set(() => {
const next = { ...snapshotState(get()), mode: "expanded" as const };
persistState(next);
return { mode: "expanded" };
});
},

minimize: () => {
set(() => {
const next = { ...snapshotState(get()), mode: "minimized" as const };
persistState(next);
return { mode: "minimized" };
});
},

setLastThreadId: (id: string) => {
set(() => {
const next = { ...snapshotState(get()), lastThreadId: id };
persistState(next);
return { lastThreadId: id };
});
},
}));
4 changes: 3 additions & 1 deletion apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,7 @@ const INTERACTION_MODE_CYCLE: readonly ProviderInteractionMode[] = ["chat", "cod

interface ChatViewProps {
threadId: ThreadId;
onMinimize?: (() => void) | undefined;
}

interface RunProjectScriptOptions {
Expand All @@ -375,7 +376,7 @@ interface RunProjectScriptOptions {
rememberAsLastInvoked?: boolean;
}

export default function ChatView({ threadId }: ChatViewProps) {
export default function ChatView({ threadId, onMinimize }: ChatViewProps) {
const clientMode = useClientMode();
const transportState = useTransportState();
const threads = useStore((store) => store.threads);
Expand Down Expand Up @@ -4849,6 +4850,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
onToggleDiffViewer={handleToggleDiffViewer}
onTogglePreview={() => activeProjectId && togglePreviewOpen(activeProjectId)}
onTogglePreviewLayout={() => activeProjectId && togglePreviewLayout(activeProjectId)}
onMinimize={onMinimize}
/>
</header>

Expand Down
16 changes: 15 additions & 1 deletion apps/web/src/components/chat/ChatHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
type ResolvedKeybindingsConfig,
} from "@okcode/contracts";
import { useQuery } from "@tanstack/react-query";
import { ExternalLinkIcon, GitPullRequestIcon } from "lucide-react";
import { ChevronDownIcon, ExternalLinkIcon, GitPullRequestIcon } from "lucide-react";
import { memo, useCallback, useEffect } from "react";
import type { ProjectScriptDraft } from "~/projectScriptDefaults";
import GitActionsControl from "../GitActionsControl";
Expand Down Expand Up @@ -59,6 +59,7 @@ interface ChatHeaderProps {
onToggleDiffViewer: () => void;
onTogglePreview: () => void;
onTogglePreviewLayout: () => void;
onMinimize?: (() => void) | undefined;
}

export const ChatHeader = memo(function ChatHeader({
Expand Down Expand Up @@ -94,6 +95,7 @@ export const ChatHeader = memo(function ChatHeader({
onToggleDiffViewer,
onTogglePreview,
onTogglePreviewLayout: _onTogglePreviewLayout,
onMinimize,
}: ChatHeaderProps) {
const isMobileCompanion = clientMode === "mobile";
const projectColor = useProjectColor(activeProjectId);
Expand Down Expand Up @@ -141,6 +143,18 @@ export const ChatHeader = memo(function ChatHeader({
{/* Left: Identity — thread title + project context */}
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden sm:gap-3">
<SidebarTrigger className="size-7 shrink-0" />
{onMinimize && (
<Button
type="button"
size="xs"
variant="ghost"
aria-label="Minimize chat"
className="size-7 shrink-0"
onClick={onMinimize}
>
<ChevronDownIcon className="size-4" />
</Button>
)}
<EditableThreadTitle
title={activeThreadTitle}
isEditing={isEditingTitle}
Expand Down
67 changes: 67 additions & 0 deletions apps/web/src/components/widget/ChatWidgetBubble.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { memo, useCallback } from "react";
import { useNavigate } from "@tanstack/react-router";
import { MessageCircleIcon } from "lucide-react";

import { useChatWidgetStore } from "../../chatWidgetStore";
import { useMobileConnectionState } from "../../hooks/useMobileConnectionState";
import { useChatWidgetStatus, type ChatWidgetTone } from "../../hooks/useChatWidgetStatus";

const TONE_DOT_CLASSES: Record<ChatWidgetTone, string> = {
idle: "bg-emerald-500 dark:bg-emerald-400",
running: "bg-blue-500 dark:bg-blue-400 animate-pulse",
attention: "bg-amber-500 dark:bg-amber-400 animate-pulse",
error: "bg-red-500 dark:bg-red-400",
};

/**
* The minimized floating pill/bubble for the chat widget.
* Shows connection status, thread title, and activity status.
* Tapping expands back to the full chat.
*/
export const ChatWidgetBubble = memo(function ChatWidgetBubble() {
const expand = useChatWidgetStore((s) => s.expand);
const lastThreadId = useChatWidgetStore((s) => s.lastThreadId);
const navigate = useNavigate();
const connectionState = useMobileConnectionState();
const { label, tone, threadTitle } = useChatWidgetStatus();

const isDisconnected = connectionState === "disconnected" || connectionState === "reconnecting";

const handleClick = useCallback(() => {
expand();
if (lastThreadId) {
void navigate({ to: "/$threadId", params: { threadId: lastThreadId } });
}
}, [expand, lastThreadId, navigate]);

return (
<button
type="button"
onClick={handleClick}
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"
aria-label="Expand chat"
>
{/* Status dot */}
<span className="relative flex size-2.5 shrink-0">
{isDisconnected ? (
<span className="size-2.5 rounded-full bg-red-500 dark:bg-red-400" />
) : (
<span className={`size-2.5 rounded-full ${TONE_DOT_CLASSES[tone]}`} />
)}
</span>

{/* Thread title or app name */}
<span className="max-w-[180px] truncate text-sm font-medium text-foreground">
{threadTitle ?? "OK Code"}
</span>

{/* Activity label */}
<span className="shrink-0 text-xs text-muted-foreground">
{isDisconnected ? "Offline" : label}
</span>

{/* Chat icon */}
<MessageCircleIcon className="size-4 shrink-0 text-muted-foreground" />
</button>
);
});
73 changes: 73 additions & 0 deletions apps/web/src/components/widget/ChatWidgetPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { type ReactNode, memo, useCallback, useRef } from "react";
import { cn } from "~/lib/utils";
import { useChatWidgetStore } from "../../chatWidgetStore";

const SWIPE_THRESHOLD_PX = 80;
const SWIPE_VELOCITY_THRESHOLD = 0.3; // px/ms

/**
* The expanded panel container for the chat widget.
* Wraps children (the full chat layout) and provides a swipe-down-to-minimize
* gesture on the top drag handle.
*/
export const ChatWidgetPanel = memo(function ChatWidgetPanel({
children,
expanded,
}: {
children: ReactNode;
expanded: boolean;
}) {
const minimize = useChatWidgetStore((s) => s.minimize);
const panelRef = useRef<HTMLDivElement>(null);
const touchStartRef = useRef<{ y: number; time: number } | null>(null);

const onTouchStart = useCallback((e: React.TouchEvent) => {
const touch = e.touches[0];
if (touch) {
touchStartRef.current = { y: touch.clientY, time: Date.now() };
}
}, []);

const onTouchEnd = useCallback(
(e: React.TouchEvent) => {
const start = touchStartRef.current;
const touch = e.changedTouches[0];
if (!start || !touch) {
touchStartRef.current = null;
return;
}

const deltaY = touch.clientY - start.y;
const elapsed = Date.now() - start.time;
const velocity = elapsed > 0 ? deltaY / elapsed : 0;

if (deltaY > SWIPE_THRESHOLD_PX || velocity > SWIPE_VELOCITY_THRESHOLD) {
minimize();
}
touchStartRef.current = null;
},
[minimize],
);

return (
<div
ref={panelRef}
className={cn(
"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",
expanded ? "translate-y-0" : "translate-y-full pointer-events-none",
)}
>
{/* Swipe-down drag handle */}
<div
className="flex h-6 shrink-0 cursor-grab items-center justify-center touch-none"
onTouchStart={onTouchStart}
onTouchEnd={onTouchEnd}
>
<div className="h-1 w-8 rounded-full bg-muted-foreground/30" />
</div>

{/* Chat content */}
<div className="flex min-h-0 flex-1 flex-col">{children}</div>
</div>
);
});
Loading
Loading