Skip to content

feat(web-dashboard): ChatKit UI Integration - #8

Merged
mjunaidca merged 2 commits into
mainfrom
005-chatkit-ui
Dec 7, 2025
Merged

mjunaidca merged 2 commits into
mainfrom
005-chatkit-ui

Conversation

@mjunaidca

Copy link
Copy Markdown
Owner

Summary

  • Integrate ChatKit UI using @openai/chatkit-react library
  • Fix script loading issue by using Next.js Script with beforeInteractive strategy
  • Implement floating chat widget with text selection "Ask" feature
  • Add page/project context awareness for contextual chat

Changes

  • ChatKitWidget component: Floating chat button that opens ChatKit panel
  • Script loading: CDN script loaded in layout.tsx head before React hydration
  • Auth integration: Requires login to use chat functionality
  • Context injection: Sends page context and user info with chat requests

Test plan

  • Click chat icon - should open ChatKit panel
  • Verify no console errors about script loading
  • Test text selection "Ask" button functionality
  • Confirm auth required (redirects to login if not authenticated)

🤖 Generated with Claude Code

- Add ChatKitWidget component using @openai/chatkit-react library
- Load ChatKit CDN script via Next.js Script with beforeInteractive strategy
- Implement floating chat button with text selection "Ask" feature
- Add page/project context awareness for chat interactions
- Include auth integration requiring login for chat functionality
- Add specs, plan, and tasks for ChatKit UI feature

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings December 7, 2025 14:18

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR integrates ChatKit UI for natural language task management in the TaskFlow dashboard, implementing a floating chat widget with authentication, context awareness, and text selection features.

Key Changes:

  • ChatKit React component integration with OpenAI's @openai/chatkit-react library
  • Custom authentication flow using httpOnly cookies and context injection
  • Floating chat widget with text selection "Ask" functionality

Reviewed changes

Copilot reviewed 12 out of 13 changed files in this pull request and generated 18 comments.

Show a summary per file
File Description
web-dashboard/src/components/chat/ChatKitWidget.tsx Main chat widget component with authentication, context extraction, and ChatKit integration
web-dashboard/src/components/chat/styles.module.css Comprehensive styling for chat widget, buttons, menus, and overlays with responsive design
web-dashboard/src/components/chat/index.tsx Export barrel for chat components
web-dashboard/src/app/layout.tsx Adds ChatKit CDN script loading with beforeInteractive strategy
web-dashboard/src/app/projects/layout.tsx Integrates ChatKitWidget into projects layout
web-dashboard/src/app/dashboard/layout.tsx Integrates ChatKitWidget into dashboard layout
web-dashboard/package.json Adds @openai/chatkit-react dependency
web-dashboard/pnpm-lock.yaml Lock file updates for new dependencies
specs/005-chatkit-ui/* Comprehensive feature specification, planning documents, and task breakdown
packages/api/AUTH_INTEGRATION.md Deleted authentication integration documentation file
Files not reviewed (1)
  • web-dashboard/pnpm-lock.yaml: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +584 to +616
{selectedText && selectionPosition && isAuthenticated && (
<div
ref={selectionRef}
className={styles.askButton}
style={{
left: `${selectionPosition.x}px`,
top: `${selectionPosition.y}px`,
}}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
handleAskSelectedText();
}}
onMouseDown={(e) => {
e.stopPropagation();
e.preventDefault();
}}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
<span>Ask</span>
</div>
)}

Copilot AI Dec 7, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "Ask" button that appears on text selection lacks proper keyboard accessibility. Users who navigate with keyboard cannot trigger this functionality since it only appears on mouse selection. Consider:

  1. Adding keyboard shortcut support (e.g., Ctrl+K or Cmd+K with selected text)
  2. Making the button focusable when it appears
  3. Announcing the button's appearance to screen readers using ARIA live regions

Example:

<div
  ref={selectionRef}
  className={styles.askButton}
  role="button"
  tabIndex={0}
  aria-label="Ask about selected text"
  onKeyDown={(e) => {
    if (e.key === 'Enter' || e.key === ' ') {
      e.preventDefault();
      handleAskSelectedText();
    }
  }}
  // ... rest of props
>

Copilot uses AI. Check for mistakes.
Comment on lines +135 to +167
useEffect(() => {
const handleSelection = () => {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
setSelectedText("");
setSelectionPosition(null);
return;
}

const text = selection.toString().trim();
if (text.length > 0) {
setSelectedText(text);

const range = selection.getRangeAt(0);
const rect = range.getBoundingClientRect();
setSelectionPosition({
x: rect.left + rect.width / 2,
y: rect.top - 10,
});
} else {
setSelectedText("");
setSelectionPosition(null);
}
};

document.addEventListener("selectionchange", handleSelection);
document.addEventListener("mouseup", handleSelection);

return () => {
document.removeEventListener("selectionchange", handleSelection);
document.removeEventListener("mouseup", handleSelection);
};
}, []);

Copilot AI Dec 7, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Memory leak: The selection event listeners are added unconditionally but never cleaned up when the component unmounts. While the cleanup function exists in the useEffect, if the component unmounts between adding listeners and the cleanup running, the listeners will persist. Consider adding a mounted ref check:

useEffect(() => {
  if (!isBrowser) return;
  
  const handleSelection = () => {
    if (!isMountedRef.current) return;
    // ... rest of handler
  };

  document.addEventListener("selectionchange", handleSelection);
  document.addEventListener("mouseup", handleSelection);

  return () => {
    document.removeEventListener("selectionchange", handleSelection);
    document.removeEventListener("mouseup", handleSelection);
  };
}, []);

Copilot uses AI. Check for mistakes.
Comment on lines +103 to +132
const getPageContext = useCallback(() => {
if (typeof window === "undefined") return null;

const metaDescription =
document
.querySelector('meta[name="description"]')
?.getAttribute("content") || "";

const mainContent =
document.querySelector("article") ||
document.querySelector("main") ||
document.querySelector('[role="main"]') ||
document.body;

const headings = Array.from(mainContent.querySelectorAll("h1, h2, h3"))
.slice(0, 5)
.map((h) => h.textContent?.trim())
.filter(Boolean)
.join(", ");

return {
url: window.location.href,
title: document.title,
path: window.location.pathname,
description: metaDescription,
headings: headings,
projectId: projectId,
timestamp: new Date().toISOString(),
};
}, [projectId]);

Copilot AI Dec 7, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The getPageContext callback is recreated on every render because it depends on projectId, but projectId changes when the URL changes. This causes the useChatKit configuration to be recreated frequently, potentially causing unnecessary re-renders or connection resets. Consider memoizing with useMemo or useCallback with proper dependencies.

Copilot uses AI. Check for mistakes.
Comment on lines +17 to +23
<head>
{/* ChatKit Web Component - must load before React components */}
<Script
src="https://cdn.platform.openai.com/deployments/chatkit/chatkit.js"
strategy="beforeInteractive"
/>
</head>

Copilot AI Dec 7, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using <head> tag directly in Next.js App Router is not recommended and may not work as expected. The <Script> component with strategy="beforeInteractive" should be placed directly in the layout without wrapping it in a <head> tag. Next.js automatically handles script placement. Remove the <head> wrapper:

<html lang="en" className="dark">
  <Script
    src="https://cdn.platform.openai.com/deployments/chatkit/chatkit.js"
    strategy="beforeInteractive"
  />
  <body className="min-h-screen bg-background font-sans antialiased">
    {children}
  </body>
</html>
Suggested change
<head>
{/* ChatKit Web Component - must load before React components */}
<Script
src="https://cdn.platform.openai.com/deployments/chatkit/chatkit.js"
strategy="beforeInteractive"
/>
</head>
{/* ChatKit Web Component - must load before React components */}
<Script
src="https://cdn.platform.openai.com/deployments/chatkit/chatkit.js"
strategy="beforeInteractive"
/>

Copilot uses AI. Check for mistakes.
Comment on lines +142 to +159
/* Hide scroll indicators */
.chatKitContainer :global([class*="scroll" i]),
.chatKitContainer :global([class*="Scroll"]),
.chatKitContainer :global([id*="scroll" i]),
.chatKitContainer :global([id*="Scroll"]),
.chatKitContainer :global([data-scroll]),
.chatKitContainer :global([aria-label*="scroll" i]),
.chatKitContainer :global([aria-label*="Scroll" i]) {
display: none !important;
visibility: hidden !important;
opacity: 0 !important;
}

/* Hide progress indicators */
.chatKitContainer :global([class*="progress" i]),
.chatKitContainer :global([class*="Progress"]),
.chatKitContainer :global([role="progressbar"]),
.chatKitContainer :global([role="status"]) {

Copilot AI Dec 7, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The overly aggressive hiding of scroll-related elements using wildcard selectors with !important may break ChatKit's internal functionality. Selectors like [class*="scroll" i] and [class*="progress" i] are too broad and could hide legitimate UI elements that contain these words in their class names (e.g., "description", "progressive", etc.). Consider more specific selectors targeting known ChatKit classes or using a less aggressive approach.

Suggested change
/* Hide scroll indicators */
.chatKitContainer :global([class*="scroll" i]),
.chatKitContainer :global([class*="Scroll"]),
.chatKitContainer :global([id*="scroll" i]),
.chatKitContainer :global([id*="Scroll"]),
.chatKitContainer :global([data-scroll]),
.chatKitContainer :global([aria-label*="scroll" i]),
.chatKitContainer :global([aria-label*="Scroll" i]) {
display: none !important;
visibility: hidden !important;
opacity: 0 !important;
}
/* Hide progress indicators */
.chatKitContainer :global([class*="progress" i]),
.chatKitContainer :global([class*="Progress"]),
.chatKitContainer :global([role="progressbar"]),
.chatKitContainer :global([role="status"]) {
/* Hide scroll indicators - more specific targeting */
/* Example: Only hide known ChatKit scrollbars or indicators */
.chatKitContainer :global(.ck-scrollbar),
.chatKitContainer :global(.ck-scroll-indicator) {
display: none !important;
visibility: hidden !important;
opacity: 0 !important;
}
/* Hide progress indicators - more specific targeting */
.chatKitContainer :global(.ck-progress-indicator),
.chatKitContainer :global([role="progressbar"]) {

Copilot uses AI. Check for mistakes.
{/* ChatKit Web Component - must load before React components */}
<Script
src="https://cdn.platform.openai.com/deployments/chatkit/chatkit.js"
strategy="beforeInteractive"

Copilot AI Dec 7, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Loading external scripts from CDN without Subresource Integrity (SRI) verification poses a security risk. If the CDN is compromised, malicious code could be injected. Consider either:

  1. Self-hosting the ChatKit script
  2. Adding integrity and crossOrigin attributes if the CDN supports SRI
  3. At minimum, documenting this security trade-off

Example with SRI (if available):

<Script
  src="https://cdn.platform.openai.com/deployments/chatkit/chatkit.js"
  strategy="beforeInteractive"
  integrity="sha384-..."
  crossOrigin="anonymous"
/>
Suggested change
strategy="beforeInteractive"
strategy="beforeInteractive"
integrity="sha384-..." // TODO: Replace with actual SRI hash for the script version in use
crossOrigin="anonymous"

Copilot uses AI. Check for mistakes.
Comment on lines +11 to +293
z-index: 1000;
}

.chatButton {
width: 56px;
height: 56px;
border-radius: 50%;
background: linear-gradient(135deg, hsl(var(--primary)) 0%, hsl(var(--primary) / 0.8) 100%);
border: 2px solid hsl(var(--primary) / 0.3);
box-shadow:
0 4px 12px hsl(var(--primary) / 0.3),
0 0 20px hsl(var(--primary) / 0.2);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: hsl(var(--primary-foreground));
transition: all 0.3s ease;
}

.settingsButton {
width: 40px;
height: 40px;
border-radius: 50%;
background: hsl(var(--primary) / 0.1);
border: 1px solid hsl(var(--primary) / 0.3);
box-shadow:
0 2px 8px hsl(var(--background) / 0.1),
0 0 0 1px hsl(var(--primary) / 0.1);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: hsl(var(--primary) / 0.9);
transition: all 0.2s ease;
backdrop-filter: blur(8px);
}

.settingsButton:hover {
background: hsl(var(--primary) / 0.2);
border-color: hsl(var(--primary) / 0.5);
transform: scale(1.1);
color: hsl(var(--primary));
}

.settingsButton:active {
transform: scale(0.95);
}

.chatButton svg {
stroke: hsl(var(--primary-foreground));
filter: drop-shadow(0 0 2px hsl(var(--primary-foreground) / 0.5));
}

.chatButton:hover {
transform: scale(1.1);
box-shadow: 0 6px 16px hsl(var(--background) / 0.4);
}

.chatButton:active {
transform: scale(0.95);
}

/* ChatKit Container */
.chatKitContainer {
position: fixed;
bottom: 90px;
right: 24px;
width: 400px;
height: 600px;
max-width: calc(100vw - 48px);
max-height: calc(100vh - 120px);
z-index: 2000;
box-shadow: 0 8px 32px hsl(var(--background) / 0.2);
border-radius: 16px;
overflow: hidden;
background: hsl(var(--background));
border: 1px solid hsl(var(--border));
animation: slideUp 0.3s ease-out;
scrollbar-width: none;
-ms-overflow-style: none;
}

.chatKitContainer::-webkit-scrollbar {
display: none;
}

@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

@keyframes slideDown {
from {
opacity: 0;
transform: translateX(-50%) translateY(-10px);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}

.chatKit {
width: 100%;
height: 100%;
border-radius: 16px;
scrollbar-width: none;
-ms-overflow-style: none;
}

.chatKit::-webkit-scrollbar {
display: none;
}

/* Hide scrollbars in ChatKit */
.chatKitContainer :global(*) {
scrollbar-width: none;
-ms-overflow-style: none;
}

.chatKitContainer :global(*)::-webkit-scrollbar {
display: none;
}

/* Hide scroll indicators */
.chatKitContainer :global([class*="scroll" i]),
.chatKitContainer :global([class*="Scroll"]),
.chatKitContainer :global([id*="scroll" i]),
.chatKitContainer :global([id*="Scroll"]),
.chatKitContainer :global([data-scroll]),
.chatKitContainer :global([aria-label*="scroll" i]),
.chatKitContainer :global([aria-label*="Scroll" i]) {
display: none !important;
visibility: hidden !important;
opacity: 0 !important;
}

/* Hide progress indicators */
.chatKitContainer :global([class*="progress" i]),
.chatKitContainer :global([class*="Progress"]),
.chatKitContainer :global([role="progressbar"]),
.chatKitContainer :global([role="status"]) {
display: none !important;
visibility: hidden !important;
}

/* Ask Button for Selected Text */
.askButton {
position: fixed;
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: hsl(var(--primary) / 0.95);
color: hsl(var(--primary-foreground));
border: 1px solid hsl(var(--primary-foreground) / 0.2);
border-radius: 8px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
box-shadow:
0 2px 8px hsl(var(--background) / 0.15),
0 0 0 1px hsl(var(--primary) / 0.1);
z-index: 1999;
transition: all 0.2s ease;
pointer-events: auto;
animation: slideDown 0.2s ease-out;
backdrop-filter: blur(8px);
transform: translateX(-50%);
white-space: nowrap;
}

.askButton:hover {
transform: translateX(-50%) translateY(-2px);
box-shadow:
0 4px 12px hsl(var(--primary) / 0.3),
0 0 0 1px hsl(var(--primary) / 0.2);
background: hsl(var(--primary));
}

.askButton:active {
transform: translateX(-50%) translateY(0);
}

.askButton svg {
flex-shrink: 0;
width: 14px;
height: 14px;
opacity: 0.9;
}

/* Login Prompt Overlay */
.loginPrompt {
position: fixed;
bottom: 90px;
right: 24px;
width: 400px;
max-width: calc(100vw - 48px);
z-index: 2000;
background: hsl(var(--background));
border: 2px solid hsl(var(--primary) / 0.3);
border-radius: 16px;
box-shadow:
0 8px 32px hsl(var(--background) / 0.3),
0 0 20px hsl(var(--primary) / 0.2);
animation: slideUp 0.3s ease-out;
}

.loginPromptContent {
padding: 2rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
text-align: center;
}

.loginPromptContent h3 {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
color: hsl(var(--foreground));
}

.loginPromptContent p {
margin: 0;
font-size: 1rem;
color: hsl(var(--muted-foreground));
line-height: 1.6;
}

.loginButton {
padding: 0.75rem 1.5rem;
background: linear-gradient(135deg, hsl(var(--primary)) 0%, hsl(var(--primary) / 0.8) 100%);
color: hsl(var(--primary-foreground));
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 4px 12px hsl(var(--primary) / 0.3);
}

.loginButton:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px hsl(var(--primary) / 0.4);
}

.loginButton:active {
transform: translateY(0);
}

.closeButton {
padding: 0.5rem 1rem;
background: transparent;
color: hsl(var(--muted-foreground));
border: 1px solid hsl(var(--border));
border-radius: 8px;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s ease;
}

.closeButton:hover {
background: hsl(var(--muted));
color: hsl(var(--foreground));
}

/* Personalize Menu */
.personalizeMenu {
position: fixed;
bottom: 90px;
right: 24px;
width: 280px;
max-width: calc(100vw - 48px);
z-index: 2001;

Copilot AI Dec 7, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The z-index values are hardcoded (1000, 1999, 2000, 2001) which may conflict with other elements in the application. Consider using CSS custom properties or a z-index scale defined in a central location to maintain consistent layering across the application:

.chatButtonContainer {
  z-index: var(--z-floating-widget, 1000);
}

.askButton {
  z-index: var(--z-overlay-below, 1999);
}

.chatKitContainer,
.loginPrompt {
  z-index: var(--z-overlay, 2000);
}

.personalizeMenu {
  z-index: var(--z-overlay-above, 2001);
}

Copilot uses AI. Check for mistakes.
Comment on lines +103 to +132
const getPageContext = useCallback(() => {
if (typeof window === "undefined") return null;

const metaDescription =
document
.querySelector('meta[name="description"]')
?.getAttribute("content") || "";

const mainContent =
document.querySelector("article") ||
document.querySelector("main") ||
document.querySelector('[role="main"]') ||
document.body;

const headings = Array.from(mainContent.querySelectorAll("h1, h2, h3"))
.slice(0, 5)
.map((h) => h.textContent?.trim())
.filter(Boolean)
.join(", ");

return {
url: window.location.href,
title: document.title,
path: window.location.pathname,
description: metaDescription,
headings: headings,
projectId: projectId,
timestamp: new Date().toISOString(),
};
}, [projectId]);

Copilot AI Dec 7, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The component queries the DOM for page context (document.querySelector) inside getPageContext, which is called in the fetch interceptor. This could cause performance issues if called frequently. Consider caching the page context or only updating it when the pathname changes:

const [pageContext, setPageContext] = useState(null);

useEffect(() => {
  setPageContext(getPageContext());
}, [pathname, projectId]);

// Then use cached pageContext in fetch

Copilot uses AI. Check for mistakes.
Comment on lines +151 to +152
x: rect.left + rect.width / 2,
y: rect.top - 10,

Copilot AI Dec 7, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The selection position is calculated using getBoundingClientRect() which returns coordinates relative to the viewport, not the document. When the page is scrolled, the "Ask" button will not appear in the correct position. Add scroll offset to fix this:

const rect = range.getBoundingClientRect();
setSelectionPosition({
  x: rect.left + rect.width / 2 + window.scrollX,
  y: rect.top - 10 + window.scrollY,
});

And update the button style to use position: absolute instead of position: fixed or account for scroll in the positioning logic.

Suggested change
x: rect.left + rect.width / 2,
y: rect.top - 10,
x: rect.left + rect.width / 2 + window.scrollX,
y: rect.top - 10 + window.scrollY,

Copilot uses AI. Check for mistakes.
Comment on lines +178 to +189
if (!isAuthenticated) {
throw new Error("User must be logged in to use chat");
}

const userId = user!.sub;
const pageContext = getPageContext();

const userInfo = {
id: user!.sub,
name: user!.name || user!.email || "User",
email: user!.email,
role: user!.role,

Copilot AI Dec 7, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The component uses user!.sub, user!.name, user!.email, and user!.role with non-null assertions after checking isAuthenticated. However, there's no guarantee that the user object is populated when isAuthenticated is true, especially during hydration or race conditions. Add explicit null checks or use optional chaining:

if (!isAuthenticated || !user) {
  throw new Error("User must be logged in to use chat");
}

const userId = user.sub;
const userInfo = {
  id: user.sub,
  name: user.name || user.email || "User",
  email: user.email,
  role: user.role,
};
Suggested change
if (!isAuthenticated) {
throw new Error("User must be logged in to use chat");
}
const userId = user!.sub;
const pageContext = getPageContext();
const userInfo = {
id: user!.sub,
name: user!.name || user!.email || "User",
email: user!.email,
role: user!.role,
if (!isAuthenticated || !user) {
throw new Error("User must be logged in to use chat");
}
const userId = user.sub;
const pageContext = getPageContext();
const userInfo = {
id: user.sub,
name: user.name || user.email || "User",
email: user.email,
role: user.role,

Copilot uses AI. Check for mistakes.
@mjunaidca
mjunaidca merged commit 7b9bc03 into main Dec 7, 2025
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants