From f221b7cbeb6359e2f07e868e22bb0481b3b14d07 Mon Sep 17 00:00:00 2001 From: Poonam Dharamkar Date: Wed, 13 May 2026 19:16:01 -0400 Subject: [PATCH 1/7] feat: integrate Ozwell AI chat widget and define tool schemas --- src/features/ai/OzwellWidget.tsx | 373 +++++++++++++++++++++++++++++++ src/features/ai/ozwell-tools.ts | 254 +++++++++++++++++++++ src/features/clock/ClockPage.tsx | 18 +- src/ui/AppLayout.tsx | 11 +- 4 files changed, 651 insertions(+), 5 deletions(-) create mode 100644 src/features/ai/OzwellWidget.tsx create mode 100644 src/features/ai/ozwell-tools.ts diff --git a/src/features/ai/OzwellWidget.tsx b/src/features/ai/OzwellWidget.tsx new file mode 100644 index 00000000..be488061 --- /dev/null +++ b/src/features/ai/OzwellWidget.tsx @@ -0,0 +1,373 @@ +/** + * OzwellWidget — mounts the Ozwell AI chat widget into the authenticated shell. + * + * Architecture: + * • Injects the CDN loader script once on mount (cleans up on unmount). + * • Syncs live context (user, team, page) via OzwellChat.updateContext() + * whenever those values change. + * • Handles all ozwell-tool-call DOM events dispatched by the widget when + * the AI calls a TimeHuddle tool. + * + * The widget renders itself (floating bubble, bottom-right). This component + * returns null — no JSX output. + * + * To configure: set VITE_OZWELL_AGENT_KEY in your .env.local. + * https://mieweb.github.io/ozwellai-api/frontend/cdn-embed + */ +import { useCallback, useEffect, useRef } from 'react'; + +import { clockApi, teamApi, ticketApi } from '../../lib/api'; +import { useSession } from '../../lib/useSession'; +import { useTeam } from '../../lib/TeamContext'; +import { useRouter } from '../../ui/router'; + +// ── Window interface augmentation ──────────────────────────────────────────── + +declare global { + interface Window { + OzwellChatConfig?: { + apiKey: string; + debug?: boolean; + }; + OzwellChat?: { + open: () => void; + close: () => void; + updateContext: (ctx: Record) => void; + }; + } +} + +// ── Tool call event type ───────────────────────────────────────────────────── + +interface OzwellToolCallDetail { + name: string; + arguments: Record; + respond: (result: unknown) => void; +} + +// ── OzwellWidget ───────────────────────────────────────────────────────────── + +const LOADER_URL = 'https://ozwellapi.opensource.mieweb.org/embed/ozwell-loader.js'; +const SCRIPT_ID = 'ozwell-loader'; + +export const OzwellWidget: React.FC = () => { + const { user } = useSession(); + const { pathname, navigate } = useRouter(); + const { selectedTeam, selectedTeamId, teams, activeClockEvent, refetchClock } = useTeam(); + + // Keep a stable ref to mutable context so the tool handler always has fresh values + // without needing to re-register the listener on every render. + const ctxRef = useRef({ + user, + pathname, + selectedTeam, + selectedTeamId, + teams, + activeClockEvent, + refetchClock, + navigate, + }); + useEffect(() => { + ctxRef.current = { + user, + pathname, + selectedTeam, + selectedTeamId, + teams, + activeClockEvent, + refetchClock, + navigate, + }; + }, [user, pathname, selectedTeam, selectedTeamId, teams, activeClockEvent, refetchClock, navigate]); + + // ── Effect 1: inject loader script once ─────────────────────────────────── + useEffect(() => { + const env = (import.meta as { env?: Record }).env; + const apiKey = env?.VITE_OZWELL_AGENT_KEY; + if (!apiKey) { + // Widget intentionally disabled when no key is configured. + return; + } + + if (document.getElementById(SCRIPT_ID)) return; // already injected + + window.OzwellChatConfig = { + apiKey, + debug: Boolean(env?.DEV), + }; + + const script = document.createElement('script'); + script.id = SCRIPT_ID; + script.src = LOADER_URL; + script.async = true; + document.body.appendChild(script); + + return () => { + document.getElementById(SCRIPT_ID)?.remove(); + delete window.OzwellChatConfig; + }; + }, []); // intentionally empty — run once + + // ── Effect 2: sync live context to widget ───────────────────────────────── + useEffect(() => { + if (!window.OzwellChat?.updateContext) return; + window.OzwellChat.updateContext({ + userId: user?.id ?? null, + userName: user?.name ?? null, + page: pathname, + teamId: selectedTeamId, + teamName: selectedTeam?.name ?? null, + }); + }, [user, pathname, selectedTeam, selectedTeamId]); + + // ── Effect 3: tool call handler ─────────────────────────────────────────── + const handleToolCall = useCallback((e: Event) => { + const { name, arguments: args, respond } = (e as CustomEvent).detail; + const ctx = ctxRef.current; + + void (async () => { + try { + switch (name) { + // Navigation + case 'navigate': { + const path = String(args.path ?? ''); + if (!path.startsWith('/app/')) { + respond({ success: false, error: 'Invalid path. Must start with /app/' }); + return; + } + ctx.navigate(path); + respond({ success: true, data: { path } }); + break; + } + + // Read context + case 'get_current_user': { + if (!ctx.user) { + respond({ success: false, error: 'No user session found' }); + return; + } + respond({ + success: true, + data: { id: ctx.user.id, name: ctx.user.name, email: ctx.user.email }, + }); + break; + } + + case 'get_current_page': { + respond({ success: true, data: { path: ctx.pathname } }); + break; + } + + case 'get_current_team': { + if (!ctx.selectedTeam) { + respond({ success: false, error: 'No team selected' }); + return; + } + respond({ success: true, data: ctx.selectedTeam }); + break; + } + + case 'get_teams': { + respond({ success: true, data: ctx.teams }); + break; + } + + // Clock + case 'get_clock_status': { + if (ctx.activeClockEvent) { + respond({ + success: true, + data: { + clockedIn: true, + since: ctx.activeClockEvent.startTime, + eventId: ctx.activeClockEvent.id, + teamId: ctx.activeClockEvent.teamId, + }, + }); + } else { + respond({ success: true, data: { clockedIn: false } }); + } + break; + } + + case 'clock_in': { + if (!ctx.selectedTeamId) { + respond({ success: false, error: 'No team selected' }); + return; + } + const event = await clockApi.start(ctx.selectedTeamId); + ctx.refetchClock(); + respond({ success: true, data: event }); + break; + } + + case 'clock_out': { + if (!ctx.selectedTeamId) { + respond({ success: false, error: 'No team selected' }); + return; + } + const stoppedEvent = await clockApi.stop(ctx.selectedTeamId); + ctx.refetchClock(); + respond({ success: true, data: stoppedEvent }); + break; + } + + case 'update_timesheet_entry': { + const entryId = String(args.id ?? ''); + if (!entryId) { + respond({ success: false, error: 'Missing required field: id' }); + return; + } + const updates: { startTime?: number; endTime?: number | null } = {}; + if (args.startTime != null) updates.startTime = new Date(String(args.startTime)).getTime(); + if (args.endTime != null) updates.endTime = new Date(String(args.endTime)).getTime(); + const updated = await clockApi.updateTimes(entryId, updates); + ctx.refetchClock(); + respond({ success: true, data: updated }); + break; + } + + case 'delete_timesheet_entry': { + const delId = String(args.id ?? ''); + if (!delId) { + respond({ success: false, error: 'Missing required field: id' }); + return; + } + await clockApi.deleteEvent(delId); + ctx.refetchClock(); + respond({ success: true }); + break; + } + + // Tickets + case 'get_tickets': { + if (!ctx.selectedTeamId) { + respond({ success: false, error: 'No team selected' }); + return; + } + const tickets = await ticketApi.getTickets(ctx.selectedTeamId); + respond({ success: true, data: tickets }); + break; + } + + case 'create_ticket': { + if (!ctx.selectedTeamId) { + respond({ success: false, error: 'No team selected' }); + return; + } + const title = String(args.title ?? ''); + if (!title) { + respond({ success: false, error: 'Missing required field: title' }); + return; + } + const ticket = await ticketApi.createTicket({ + teamId: ctx.selectedTeamId, + title, + }); + respond({ success: true, data: ticket }); + break; + } + + case 'update_ticket': { + const ticketId = String(args.id ?? ''); + if (!ticketId) { + respond({ success: false, error: 'Missing required field: id' }); + return; + } + const hasStatusOrPriority = args.status != null || args.priority != null; + const hasTitleOrDescription = args.title != null || args.description != null; + + let updatedTicket; + if (hasStatusOrPriority && !hasTitleOrDescription) { + updatedTicket = await ticketApi.updateStatusPriority(ticketId, { + status: args.status as string | undefined, + priority: args.priority as string | undefined, + }); + } else if (hasTitleOrDescription && !hasStatusOrPriority) { + updatedTicket = await ticketApi.updateTicket(ticketId, { + title: args.title as string | undefined, + description: args.description as string | undefined, + }); + } else { + // Both: do two calls, return the final state + if (hasTitleOrDescription) { + await ticketApi.updateTicket(ticketId, { + title: args.title as string | undefined, + description: args.description as string | undefined, + }); + } + updatedTicket = await ticketApi.updateStatusPriority(ticketId, { + status: args.status as string | undefined, + priority: args.priority as string | undefined, + }); + } + respond({ success: true, data: updatedTicket }); + break; + } + + case 'delete_ticket': { + const delTicketId = String(args.id ?? ''); + if (!delTicketId) { + respond({ success: false, error: 'Missing required field: id' }); + return; + } + await ticketApi.deleteTicket(delTicketId); + respond({ success: true }); + break; + } + + // Teams + case 'create_team': { + const teamName = String(args.name ?? ''); + if (!teamName) { + respond({ success: false, error: 'Missing required field: name' }); + return; + } + const newTeam = await teamApi.createTeam({ + name: teamName, + description: args.description as string | undefined, + }); + respond({ success: true, data: newTeam }); + break; + } + + case 'update_team': { + const updateTeamId = String(args.id ?? ''); + const newName = String(args.name ?? ''); + if (!updateTeamId || !newName) { + respond({ success: false, error: 'Missing required fields: id and name' }); + return; + } + const renamedTeam = await teamApi.renameTeam(updateTeamId, newName); + respond({ success: true, data: renamedTeam }); + break; + } + + case 'delete_team': { + const delTeamId = String(args.id ?? ''); + if (!delTeamId) { + respond({ success: false, error: 'Missing required field: id' }); + return; + } + await teamApi.deleteTeam(delTeamId); + respond({ success: true }); + break; + } + + default: + respond({ success: false, error: `Unknown tool: ${name}` }); + } + } catch (err) { + const message = err instanceof Error ? err.message : 'An unexpected error occurred'; + respond({ success: false, error: message }); + } + })(); + }, []); // stable — reads ctx via ref + + useEffect(() => { + document.addEventListener('ozwell-tool-call', handleToolCall); + return () => document.removeEventListener('ozwell-tool-call', handleToolCall); + }, [handleToolCall]); + + return null; +}; diff --git a/src/features/ai/ozwell-tools.ts b/src/features/ai/ozwell-tools.ts new file mode 100644 index 00000000..22800027 --- /dev/null +++ b/src/features/ai/ozwell-tools.ts @@ -0,0 +1,254 @@ +/** + * Tool schemas for the Ozwell AI agent. + * + * These definitions are used when registering tools with the Ozwell Agent + * Registration API (server-side) and optionally inline via OzwellChatConfig + * when using a parent key. The tool handlers themselves live in OzwellWidget.tsx. + * + * https://mieweb.github.io/ozwellai-api/backend/agents + */ + +export interface OzwellToolParam { + type: string; + description: string; + enum?: string[]; +} + +export interface OzwellTool { + type: 'function'; + function: { + name: string; + description: string; + parameters: { + type: 'object'; + properties: Record; + required: string[]; + }; + }; +} + +export const OZWELL_TOOLS: OzwellTool[] = [ + // ── Navigation ───────────────────────────────────────────────────────────── + { + type: 'function', + function: { + name: 'navigate', + description: + 'Navigate the user to a different page in the TimeHuddle app. Use this when the user asks to go somewhere or open a section.', + parameters: { + type: 'object', + properties: { + path: { + type: 'string', + description: + 'App route path, e.g. /app/dashboard, /app/clock, /app/tickets, /app/work, /app/timesheet, /app/teams, /app/messages, /app/notifications, /app/activity, /app/settings', + }, + }, + required: ['path'], + }, + }, + }, + + // ── Read context ──────────────────────────────────────────────────────────── + { + type: 'function', + function: { + name: 'get_current_user', + description: "Get the currently logged-in user's name, email, and ID.", + parameters: { type: 'object', properties: {}, required: [] }, + }, + }, + { + type: 'function', + function: { + name: 'get_current_page', + description: 'Get the current page/route the user is viewing.', + parameters: { type: 'object', properties: {}, required: [] }, + }, + }, + { + type: 'function', + function: { + name: 'get_current_team', + description: "Get the currently selected team's name, ID, and member count.", + parameters: { type: 'object', properties: {}, required: [] }, + }, + }, + { + type: 'function', + function: { + name: 'get_teams', + description: 'List all teams the user belongs to.', + parameters: { type: 'object', properties: {}, required: [] }, + }, + }, + + // ── Clock ─────────────────────────────────────────────────────────────────── + { + type: 'function', + function: { + name: 'get_clock_status', + description: 'Check whether the user is currently clocked in or out and when they started.', + parameters: { type: 'object', properties: {}, required: [] }, + }, + }, + { + type: 'function', + function: { + name: 'clock_in', + description: 'Clock the user in for the currently selected team.', + parameters: { type: 'object', properties: {}, required: [] }, + }, + }, + { + type: 'function', + function: { + name: 'clock_out', + description: 'Clock the user out for the currently selected team.', + parameters: { type: 'object', properties: {}, required: [] }, + }, + }, + { + type: 'function', + function: { + name: 'update_timesheet_entry', + description: 'Update the start or end time of a timesheet (clock) entry by its ID.', + parameters: { + type: 'object', + properties: { + id: { type: 'string', description: 'Clock event ID to update' }, + startTime: { type: 'string', description: 'New start time as ISO 8601 string (optional)' }, + endTime: { type: 'string', description: 'New end time as ISO 8601 string (optional)' }, + }, + required: ['id'], + }, + }, + }, + { + type: 'function', + function: { + name: 'delete_timesheet_entry', + description: 'Delete a timesheet (clock) entry by its ID. This cannot be undone.', + parameters: { + type: 'object', + properties: { + id: { type: 'string', description: 'Clock event ID to delete' }, + }, + required: ['id'], + }, + }, + }, + + // ── Tickets ───────────────────────────────────────────────────────────────── + { + type: 'function', + function: { + name: 'get_tickets', + description: 'Get all tickets for the currently selected team.', + parameters: { type: 'object', properties: {}, required: [] }, + }, + }, + { + type: 'function', + function: { + name: 'create_ticket', + description: 'Create a new ticket in the currently selected team.', + parameters: { + type: 'object', + properties: { + title: { type: 'string', description: 'Ticket title' }, + description: { type: 'string', description: 'Optional ticket description' }, + }, + required: ['title'], + }, + }, + }, + { + type: 'function', + function: { + name: 'update_ticket', + description: + "Update a ticket's title, description, status, or priority. Only provide the fields you want to change.", + parameters: { + type: 'object', + properties: { + id: { type: 'string', description: 'Ticket ID to update' }, + title: { type: 'string', description: 'New title (optional)' }, + description: { type: 'string', description: 'New description (optional)' }, + status: { + type: 'string', + description: 'New status (optional)', + enum: ['open', 'in-progress', 'done', 'blocked'], + }, + priority: { + type: 'string', + description: 'New priority (optional)', + enum: ['low', 'medium', 'high', 'critical'], + }, + }, + required: ['id'], + }, + }, + }, + { + type: 'function', + function: { + name: 'delete_ticket', + description: 'Delete a ticket by its ID. This cannot be undone — confirm with the user first.', + parameters: { + type: 'object', + properties: { + id: { type: 'string', description: 'Ticket ID to delete' }, + }, + required: ['id'], + }, + }, + }, + + // ── Teams ─────────────────────────────────────────────────────────────────── + { + type: 'function', + function: { + name: 'create_team', + description: 'Create a new team.', + parameters: { + type: 'object', + properties: { + name: { type: 'string', description: 'Team name' }, + description: { type: 'string', description: 'Optional team description' }, + }, + required: ['name'], + }, + }, + }, + { + type: 'function', + function: { + name: 'update_team', + description: 'Rename a team by its ID.', + parameters: { + type: 'object', + properties: { + id: { type: 'string', description: 'Team ID to rename' }, + name: { type: 'string', description: 'New team name' }, + }, + required: ['id', 'name'], + }, + }, + }, + { + type: 'function', + function: { + name: 'delete_team', + description: + 'Delete a team by its ID. This is destructive and cannot be undone — always confirm with the user first.', + parameters: { + type: 'object', + properties: { + id: { type: 'string', description: 'Team ID to delete' }, + }, + required: ['id'], + }, + }, + }, +]; diff --git a/src/features/clock/ClockPage.tsx b/src/features/clock/ClockPage.tsx index 8b64336f..b5d0994a 100644 --- a/src/features/clock/ClockPage.tsx +++ b/src/features/clock/ClockPage.tsx @@ -10,11 +10,13 @@ import { useTeam } from '../../lib/TeamContext'; import { formatTimer } from '../../lib/timeUtils'; import { AppPage } from '../../ui/AppPage'; import { useClockToggle } from '../../lib/useClockToggle'; +import { useRouter } from '../../ui/router'; // ─── ClockPage ──────────────────────────────────────────────────────────────── export const ClockPage: React.FC = () => { const { selectedTeamId, activeClockEvent, currentTime, teamsReady } = useTeam(); + const { navigate } = useRouter(); const { clockIn, clockOut, clockInLoading, clockOutLoading } = useClockToggle(); @@ -124,13 +126,21 @@ export const ClockPage: React.FC = () => { Coming soon… In the meantime, track your time on the{' '} - + {' '} page or manage your{' '} - + . diff --git a/src/ui/AppLayout.tsx b/src/ui/AppLayout.tsx index da27d9ba..688ee0f1 100644 --- a/src/ui/AppLayout.tsx +++ b/src/ui/AppLayout.tsx @@ -12,7 +12,7 @@ * * SidebarContext owns expand/collapse + mobile drawer state. */ -import React, { createContext, useCallback, useContext, useEffect, useState } from 'react'; +import React, { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { Capacitor } from '@capacitor/core'; import { PushNotifications } from '@capacitor/push-notifications'; @@ -27,6 +27,7 @@ import { TeamsPage } from '../features/teams/TeamsPage'; import { TicketsPage } from '../features/tickets/TicketsPage'; import { TicketDetailPage } from '../features/tickets/TicketDetailPage'; import { WorkPage } from '../features/timers/WorkPage'; +import { OzwellWidget } from '../features/ai/OzwellWidget'; import { ActivityLogPage } from '../features/activity/ActivityLogPage'; import { SIDEBAR_KEY, MESSAGES_PENDING_THREAD_KEY } from '../lib/constants'; import { TeamProvider } from '../lib/TeamContext'; @@ -125,6 +126,12 @@ export const AppLayout: React.FC = () => { return () => window.removeEventListener('popstate', onPop); }, []); + // Scroll the content area back to the top on every route change + const mainRef = useRef(null); + useEffect(() => { + mainRef.current?.scrollTo({ top: 0 }); + }, [pathname]); + // Native push notification tap → navigate to the relevant page useEffect(() => { if (!Capacitor.isNativePlatform()) return; @@ -244,6 +251,7 @@ export const AppLayout: React.FC = () => {
{profileUserId ? ( @@ -261,6 +269,7 @@ export const AppLayout: React.FC = () => { {(!isMessagesPage || !messagesHasActiveChat) && }
+ From f4509d891d3d448de62ce8df1a4eb276bbdb29fa Mon Sep 17 00:00:00 2001 From: Poonam Dharamkar Date: Thu, 14 May 2026 15:49:33 -0400 Subject: [PATCH 2/7] feat: enhance OzwellWidget with timer API integration and Jerry button styling Co-authored-by: Copilot --- src/features/ai/OzwellWidget.tsx | 271 ++++++++++++++++++++++++++++++- 1 file changed, 268 insertions(+), 3 deletions(-) diff --git a/src/features/ai/OzwellWidget.tsx b/src/features/ai/OzwellWidget.tsx index be488061..fb74f551 100644 --- a/src/features/ai/OzwellWidget.tsx +++ b/src/features/ai/OzwellWidget.tsx @@ -16,7 +16,7 @@ */ import { useCallback, useEffect, useRef } from 'react'; -import { clockApi, teamApi, ticketApi } from '../../lib/api'; +import { clockApi, teamApi, ticketApi, timerApi } from '../../lib/api'; import { useSession } from '../../lib/useSession'; import { useTeam } from '../../lib/TeamContext'; import { useRouter } from '../../ui/router'; @@ -49,6 +49,178 @@ interface OzwellToolCallDetail { const LOADER_URL = 'https://ozwellapi.opensource.mieweb.org/embed/ozwell-loader.js'; const SCRIPT_ID = 'ozwell-loader'; +const MOBILE_OVERRIDE_STYLE_ID = 'ozwell-mobile-override'; +const JERRY_BUTTON_STYLE_ID = 'ozwell-jerry-button'; + +/** Inject CSS for the Jerry animated avatar button. */ +function injectJerryButtonStyles() { + if (document.getElementById(JERRY_BUTTON_STYLE_ID)) return; + const style = document.createElement('style'); + style.id = JERRY_BUTTON_STYLE_ID; + style.textContent = ` + #ozwell-chat-button { + background: #F5A623 !important; + border-radius: 14px !important; + box-shadow: 0 4px 16px rgba(245, 166, 35, 0.4) !important; + flex-direction: column !important; + gap: 2px !important; + animation: jerry-bob 3.5s ease-in-out infinite !important; + } + #ozwell-chat-button:hover { + box-shadow: 0 6px 22px rgba(245, 166, 35, 0.55) !important; + } + #ozwell-chat-button.wiggling { + animation: ozwell-wiggle 0.8s ease-in-out !important; + } + @keyframes jerry-bob { + 0%, 100% { transform: translateY(0px); } + 50% { transform: translateY(-5px); } + } + .jerry-eyes { + display: flex; + gap: 8px; + } + .jerry-eye { + width: 5px; + height: 5px; + border-radius: 50%; + background: #3B2000; + animation: jerry-blink 5s ease-in-out infinite; + transform-origin: center; + } + .jerry-eye.r { animation-delay: 0.07s; } + @keyframes jerry-blink { + 0%, 88%, 100% { transform: scaleY(1); } + 93% { transform: scaleY(0.08); } + } + .jerry-j { + font-size: 24px; + font-weight: 700; + line-height: 1; + color: #3B2000; + font-family: Georgia, 'Times New Roman', serif; + } + .jerry-wave { + position: absolute; + top: -8px; + right: -10px; + font-size: 16px; + animation: jerry-wave 2.8s ease-in-out 0.5s both; + transform-origin: 70% 80%; + z-index: 1; + pointer-events: none; + } + @keyframes jerry-wave { + 0% { opacity: 0; transform: rotate(-20deg) scale(0.4); } + 12% { opacity: 1; transform: rotate(15deg) scale(1); } + 28% { transform: rotate(-10deg); } + 42% { transform: rotate(14deg); } + 56% { transform: rotate(-8deg); } + 72% { transform: rotate(6deg); } + 88% { transform: rotate(0deg); } + 100% { opacity: 1; transform: rotate(0deg); } + } + `; + document.head.appendChild(style); +} + +/** Replace the loader's default favicon icon with the Jerry animated avatar. */ +function injectJerryButtonContent() { + const button = document.getElementById('ozwell-chat-button'); + if (!button) return; + button.innerHTML = ` +
👋
+
+
+
+
+
J
+ `; +} + +/** Override the loader's full-screen mobile styles — bottom-sheet pattern. */ +function injectMobileOverride() { + if (document.getElementById(MOBILE_OVERRIDE_STYLE_ID)) return; + const style = document.createElement('style'); + style.id = MOBILE_OVERRIDE_STYLE_ID; + style.textContent = ` + @media (max-width: 767px) { + /* FAB: sit above the bottom nav bar */ + #ozwell-chat-button { + bottom: calc(72px + env(safe-area-inset-bottom)) !important; + right: 20px !important; + width: 52px !important; + height: 52px !important; + } + + /* Backdrop that dims the app when chat is open */ + #ozwell-chat-wrapper::before { + content: '' !important; + display: block !important; + position: fixed !important; + inset: 0 !important; + background: rgba(0, 0, 0, 0.45) !important; + z-index: -1 !important; + transition: opacity 0.3s !important; + } + #ozwell-chat-wrapper.hidden::before { + opacity: 0 !important; + } + #ozwell-chat-wrapper.visible::before { + opacity: 1 !important; + } + + /* Bottom sheet: slides up from the bottom */ + #ozwell-chat-wrapper { + position: fixed !important; + top: auto !important; + left: 0 !important; + right: 0 !important; + bottom: 0 !important; + width: 100% !important; + height: 72vh !important; + max-height: 600px !important; + border-radius: 20px 20px 0 0 !important; + border: none !important; + border-top: 1px solid #e5e7eb !important; + box-shadow: 0 -4px 32px rgba(0, 0, 0, 0.18) !important; + padding-bottom: env(safe-area-inset-bottom) !important; + z-index: 9999 !important; + } + #ozwell-chat-wrapper.hidden { + opacity: 1 !important; + transform: translateY(100%) !important; + pointer-events: none !important; + } + #ozwell-chat-wrapper.visible { + opacity: 1 !important; + transform: translateY(0) !important; + } + + /* Drag handle pill at the top of the sheet */ + .ozwell-chat-header::before { + content: '' !important; + display: block !important; + width: 36px !important; + height: 4px !important; + background: rgba(255,255,255,0.5) !important; + border-radius: 2px !important; + margin: 0 auto 10px !important; + } + .ozwell-chat-header { + padding-top: 12px !important; + flex-direction: column !important; + align-items: stretch !important; + } + .ozwell-chat-controls { + display: flex !important; + justify-content: flex-end !important; + margin-top: -8px !important; + } + } + `; + document.head.appendChild(style); +} export const OzwellWidget: React.FC = () => { const { user } = useSession(); @@ -96,6 +268,9 @@ export const OzwellWidget: React.FC = () => { debug: Boolean(env?.DEV), }; + injectMobileOverride(); + injectJerryButtonStyles(); // inject before script so button is styled on creation + const script = document.createElement('script'); script.id = SCRIPT_ID; script.src = LOADER_URL; @@ -104,11 +279,25 @@ export const OzwellWidget: React.FC = () => { return () => { document.getElementById(SCRIPT_ID)?.remove(); + document.getElementById(MOBILE_OVERRIDE_STYLE_ID)?.remove(); + document.getElementById(JERRY_BUTTON_STYLE_ID)?.remove(); delete window.OzwellChatConfig; }; }, []); // intentionally empty — run once - // ── Effect 2: sync live context to widget ───────────────────────────────── + // ── Effect 2: inject Jerry button once widget is ready ──────────────────── + useEffect(() => { + const onReady = () => { + injectJerryButtonStyles(); + injectJerryButtonContent(); + }; + document.addEventListener('ozwell-chat-ready', onReady); + // Widget may already be ready if this effect runs late + if (document.getElementById('ozwell-chat-button')) onReady(); + return () => document.removeEventListener('ozwell-chat-ready', onReady); + }, []); + + // ── Effect 3: sync live context to widget ───────────────────────────────── useEffect(() => { if (!window.OzwellChat?.updateContext) return; window.OzwellChat.updateContext({ @@ -120,7 +309,7 @@ export const OzwellWidget: React.FC = () => { }); }, [user, pathname, selectedTeam, selectedTeamId]); - // ── Effect 3: tool call handler ─────────────────────────────────────────── + // ── Effect 4: tool call handler ─────────────────────────────────────────── const handleToolCall = useCallback((e: Event) => { const { name, arguments: args, respond } = (e as CustomEvent).detail; const ctx = ctxRef.current; @@ -354,6 +543,82 @@ export const OzwellWidget: React.FC = () => { break; } + // Work / Timers + case 'get_work_items': { + const date = String(args.date ?? new Date().toLocaleDateString('en-CA')); + const entries = await timerApi.getDay(date); + respond({ success: true, data: entries }); + break; + } + + case 'create_work_item': { + const ticketId = String(args.ticketId ?? ''); + if (!ticketId) { + respond({ success: false, error: 'Missing required field: ticketId' }); + return; + } + const date = String(args.date ?? new Date().toLocaleDateString('en-CA')); + const entry = await timerApi.createEntry({ ticketId, date, note: args.note as string | undefined }); + respond({ success: true, data: entry }); + break; + } + + case 'start_work_timer': { + const entryId = String(args.workItemId ?? ''); + if (!entryId) { + respond({ success: false, error: 'Missing required field: workItemId' }); + return; + } + const result = await timerApi.startSession(entryId); + respond({ success: true, data: result }); + break; + } + + case 'stop_work_timer': { + const sessionId = String(args.sessionId ?? ''); + if (!sessionId) { + respond({ success: false, error: 'Missing required field: sessionId' }); + return; + } + const stopped = await timerApi.stopSession(sessionId); + respond({ success: true, data: stopped }); + break; + } + + case 'get_running_timer': { + const running = await timerApi.getRunning(); + respond({ success: true, data: running ?? null }); + break; + } + + /** + * Compound action: create a ticket (if no ticketId provided), create a work item + * for today, and immediately start the timer. This is the single tool the AI should + * call when the user says "start working on X". + */ + case 'start_ticket_timer': { + if (!ctx.selectedTeamId) { + respond({ success: false, error: 'No team selected' }); + return; + } + // Resolve ticketId — caller may pass an existing id or a title to create + let ticketId = String(args.ticketId ?? ''); + const ticketTitle = String(args.title ?? ''); + if (!ticketId) { + if (!ticketTitle) { + respond({ success: false, error: 'Provide ticketId or title' }); + return; + } + const newTicket = await ticketApi.createTicket({ teamId: ctx.selectedTeamId, title: ticketTitle }); + ticketId = newTicket.id; + } + const today = new Date().toLocaleDateString('en-CA'); + const entry = await timerApi.createEntry({ ticketId, date: today }); + const session = await timerApi.startSession(entry.id); + respond({ success: true, data: { ticket: { id: ticketId }, workItem: entry, session } }); + break; + } + default: respond({ success: false, error: `Unknown tool: ${name}` }); } From b8563bab60e11a70ad48160eb212fd34da2dc70c Mon Sep 17 00:00:00 2001 From: Poonam Dharamkar Date: Mon, 25 May 2026 13:13:19 -0400 Subject: [PATCH 3/7] fix: update server host to "::" for IPv6 compatibility; enhance OzwellWidget with GitHub issue integration and new team switching functionality; improve API request timeout error handling Co-authored-by: Copilot --- backend/src/server.ts | 2 +- src/features/ai/OzwellWidget.tsx | 318 +++++++++++++++++++++++++-- src/features/ai/ozwell-tools.ts | 176 ++++++++++++++- src/features/tickets/TicketsPage.tsx | 8 +- src/lib/api.ts | 11 +- 5 files changed, 484 insertions(+), 31 deletions(-) diff --git a/backend/src/server.ts b/backend/src/server.ts index 773026ff..d76cd8af 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -510,7 +510,7 @@ async function bootstrap() { } const app = await buildApp(); const port = Number(process.env.PORT) || 4000; - await app.listen({ port, host: "0.0.0.0" }); + await app.listen({ port, host: "::" }); console.log(`API running on http://localhost:${port}`); console.log(`Swagger UI at http://localhost:${port}/docs`); } diff --git a/src/features/ai/OzwellWidget.tsx b/src/features/ai/OzwellWidget.tsx index fb74f551..8e06f3e6 100644 --- a/src/features/ai/OzwellWidget.tsx +++ b/src/features/ai/OzwellWidget.tsx @@ -18,9 +18,24 @@ import { useCallback, useEffect, useRef } from 'react'; import { clockApi, teamApi, ticketApi, timerApi } from '../../lib/api'; import { useSession } from '../../lib/useSession'; +import { + createTicketFromGithub, + fetchGithubIssue, + isGithubIssueUrl, +} from '../tickets/githubIssue'; import { useTeam } from '../../lib/TeamContext'; import { useRouter } from '../../ui/router'; +/** Format a number of seconds into a human-readable string, e.g. "2h 15m" or "45m". */ +function formatDuration(totalSeconds: number): string { + const s = Math.max(0, Math.round(totalSeconds)); + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + if (h > 0 && m > 0) return `${h}h ${m}m`; + if (h > 0) return `${h}h`; + return `${m}m`; +} + // ── Window interface augmentation ──────────────────────────────────────────── declare global { @@ -28,6 +43,8 @@ declare global { OzwellChatConfig?: { apiKey: string; debug?: boolean; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + tools?: any[]; }; OzwellChat?: { open: () => void; @@ -225,7 +242,7 @@ function injectMobileOverride() { export const OzwellWidget: React.FC = () => { const { user } = useSession(); const { pathname, navigate } = useRouter(); - const { selectedTeam, selectedTeamId, teams, activeClockEvent, refetchClock } = useTeam(); + const { selectedTeam, selectedTeamId, teams, activeClockEvent, refetchClock, setSelectedTeamId } = useTeam(); // Keep a stable ref to mutable context so the tool handler always has fresh values // without needing to re-register the listener on every render. @@ -238,6 +255,7 @@ export const OzwellWidget: React.FC = () => { activeClockEvent, refetchClock, navigate, + setSelectedTeamId, }); useEffect(() => { ctxRef.current = { @@ -249,8 +267,9 @@ export const OzwellWidget: React.FC = () => { activeClockEvent, refetchClock, navigate, + setSelectedTeamId, }; - }, [user, pathname, selectedTeam, selectedTeamId, teams, activeClockEvent, refetchClock, navigate]); + }, [user, pathname, selectedTeam, selectedTeamId, teams, activeClockEvent, refetchClock, navigate, setSelectedTeamId]); // ── Effect 1: inject loader script once ─────────────────────────────────── useEffect(() => { @@ -306,8 +325,18 @@ export const OzwellWidget: React.FC = () => { page: pathname, teamId: selectedTeamId, teamName: selectedTeam?.name ?? null, + today: new Date().toLocaleDateString('en-CA'), + // Clock status — Jerry reads this directly instead of calling get_clock_status + clockedIn: activeClockEvent != null, + clockedInTeamId: activeClockEvent?.teamId ?? null, + clockedInTeamName: activeClockEvent + ? (teams.find((t) => t.id === activeClockEvent.teamId)?.name ?? null) + : null, + clockedInSince: activeClockEvent + ? new Date(activeClockEvent.startTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + : null, }); - }, [user, pathname, selectedTeam, selectedTeamId]); + }, [user, pathname, selectedTeam, selectedTeamId, activeClockEvent, teams]); // ── Effect 4: tool call handler ─────────────────────────────────────────── const handleToolCall = useCallback((e: Event) => { @@ -362,13 +391,79 @@ export const OzwellWidget: React.FC = () => { } // Clock + case 'get_clock_sessions': { + if (!user?.id) { + respond({ success: false, error: 'No user session found' }); + return; + } + const todayStr = new Date().toLocaleDateString('en-CA'); + const startDateStr = String(args.start_date ?? todayStr); + const endDateStr = String(args.end_date ?? startDateStr); + // Convert local dates to epoch ms boundaries + const startMs = new Date(`${startDateStr}T00:00:00`).getTime(); + const endMs = new Date(`${endDateStr}T23:59:59.999`).getTime(); + const result = await clockApi.getTimesheet(user.id, startMs, endMs); + + // Resolve team filter: explicit arg takes priority, then selected team + const argTeamId = args.team_id ? String(args.team_id) : undefined; + const argTeamName = args.team_name ? String(args.team_name).toLowerCase() : undefined; + const resolvedByName = argTeamName + ? ctx.teams.find((t) => t.name.toLowerCase().includes(argTeamName)) + : undefined; + const filterTeamId = argTeamId ?? resolvedByName?.id ?? ctx.selectedTeamId; + const filteredSessions = filterTeamId + ? result.sessions.filter((s) => s.teamId === filterTeamId) + : result.sessions; + + // Compute work seconds the same way TimesheetPage does (uses accumulatedTime when set) + const now = Date.now(); + const getWorkSeconds = (s: (typeof filteredSessions)[number]): number => { + if (!s.endTime) { + const acc = Math.max(0, s.accumulatedTime ?? 0); + return acc + Math.max(0, Math.floor((now - s.startTime) / 1000)); + } + const acc = Math.max(0, s.accumulatedTime ?? 0); + if (acc > 0) return acc; + return Math.max(0, Math.floor((s.endTime - s.startTime) / 1000)); + }; + + const totalSeconds = filteredSessions.reduce((sum, s) => sum + getWorkSeconds(s), 0); + const sessions = filteredSessions.map((s) => ({ + id: s.id, + date: new Date(s.startTime).toLocaleDateString([], { year: 'numeric', month: 'short', day: 'numeric' }), + clockIn: new Date(s.startTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), + clockOut: s.endTime + ? new Date(s.endTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + : 'still clocked in', + duration: formatDuration(getWorkSeconds(s)), + teamId: s.teamId, + team: ctx.teams.find((t) => t.id === s.teamId)?.name ?? s.teamId, + })); + respond({ + success: true, + data: { + startDate: startDateStr, + endDate: endDateStr, + teamFilter: filterTeamId + ? (ctx.teams.find((t) => t.id === filterTeamId)?.name ?? filterTeamId) + : 'all teams', + sessions, + totalSessions: filteredSessions.length, + grandTotal: formatDuration(totalSeconds), + }, + }); + break; + } + case 'get_clock_status': { if (ctx.activeClockEvent) { + const elapsedSeconds = (Date.now() - new Date(ctx.activeClockEvent.startTime).getTime()) / 1000; respond({ success: true, data: { clockedIn: true, since: ctx.activeClockEvent.startTime, + elapsed: formatDuration(elapsedSeconds), eventId: ctx.activeClockEvent.id, teamId: ctx.activeClockEvent.teamId, }, @@ -381,7 +476,11 @@ export const OzwellWidget: React.FC = () => { case 'clock_in': { if (!ctx.selectedTeamId) { - respond({ success: false, error: 'No team selected' }); + const available = ctx.teams.map((t) => `"${t.name}"`).join(', '); + respond({ + success: false, + error: `No team selected. Use switch_team first. Available teams: ${available}`, + }); return; } const event = await clockApi.start(ctx.selectedTeamId); @@ -444,15 +543,56 @@ export const OzwellWidget: React.FC = () => { respond({ success: false, error: 'No team selected' }); return; } - const title = String(args.title ?? ''); + let title = String(args.title ?? ''); + const githubUrl = String(args.github ?? ''); + const descriptionArg = args.description ? String(args.description) : undefined; + if (!title) { respond({ success: false, error: 'Missing required field: title' }); return; } + + // If the title IS a GitHub URL, auto-fetch the real title + body + if (isGithubIssueUrl(title)) { + const issue = await fetchGithubIssue(title); + const url = title; + title = issue?.title ?? title; + const description = descriptionArg ?? issue?.body ?? null; + const ticket = await createTicketFromGithub({ + teamId: ctx.selectedTeamId, + url, + title, + description, + }); + window.dispatchEvent(new Event('tickets:refetch')); + respond({ success: true, data: ticket }); + break; + } + + // If a separate github URL is provided, use createTicketFromGithub + if (githubUrl && isGithubIssueUrl(githubUrl)) { + const issue = await fetchGithubIssue(githubUrl); + const description = descriptionArg ?? issue?.body ?? null; + const ticket = await createTicketFromGithub({ + teamId: ctx.selectedTeamId, + url: githubUrl, + title, + description, + }); + window.dispatchEvent(new Event('tickets:refetch')); + respond({ success: true, data: ticket }); + break; + } + + // Plain ticket const ticket = await ticketApi.createTicket({ teamId: ctx.selectedTeamId, title, }); + if (descriptionArg) { + await ticketApi.updateTicket(ticket.id, { description: descriptionArg }); + } + window.dispatchEvent(new Event('tickets:refetch')); respond({ success: true, data: ticket }); break; } @@ -490,6 +630,7 @@ export const OzwellWidget: React.FC = () => { priority: args.priority as string | undefined, }); } + window.dispatchEvent(new Event('tickets:refetch')); respond({ success: true, data: updatedTicket }); break; } @@ -501,11 +642,57 @@ export const OzwellWidget: React.FC = () => { return; } await ticketApi.deleteTicket(delTicketId); + window.dispatchEvent(new Event('tickets:refetch')); respond({ success: true }); break; } // Teams + case 'switch_team': { + // Try teamId first, then fall back to name match (case-insensitive). + // Using explicit truthiness check so empty-string args don't mask the other field. + const teamIdArg = args.teamId ? String(args.teamId) : ''; + const teamNameArg = args.name ? String(args.name) : ''; + + if (!teamIdArg && !teamNameArg) { + const available = ctx.teams.map((t) => `"${t.name}" (${t.id})`).join(', '); + respond({ + success: false, + error: `Provide teamId or name. Available teams: ${available}`, + }); + return; + } + + const matchedTeam = + (teamIdArg ? ctx.teams.find((t) => t.id === teamIdArg) : undefined) ?? + (teamNameArg + ? ctx.teams.find((t) => t.name.toLowerCase() === teamNameArg.toLowerCase()) + : undefined); + + if (!matchedTeam) { + const query = teamIdArg || teamNameArg; + const available = ctx.teams.map((t) => `"${t.name}"`).join(', '); + respond({ + success: false, + error: `Team "${query}" not found. Available teams: ${available}`, + }); + return; + } + + // Already on this team — nothing to do + if (matchedTeam.id === ctx.selectedTeamId) { + respond({ + success: true, + data: { id: matchedTeam.id, name: matchedTeam.name, alreadySelected: true }, + }); + return; + } + + ctx.setSelectedTeamId(matchedTeam.id); + respond({ success: true, data: { id: matchedTeam.id, name: matchedTeam.name } }); + break; + } + case 'create_team': { const teamName = String(args.name ?? ''); if (!teamName) { @@ -547,7 +734,32 @@ export const OzwellWidget: React.FC = () => { case 'get_work_items': { const date = String(args.date ?? new Date().toLocaleDateString('en-CA')); const entries = await timerApi.getDay(date); - respond({ success: true, data: entries }); + const now = Date.now(); + let grandTotalSeconds = 0; + const formatted = entries.map((e) => { + const totalSeconds = e.sessions.reduce((sum, s) => { + const end = s.endTime ?? now; + return sum + (end - s.startTime) / 1000; + }, 0); + grandTotalSeconds += totalSeconds; + return { + ticket: e.entry.displayTitle ?? e.entry.ticketId, + workItemId: e.entry.id, + total: formatDuration(totalSeconds), + sessions: e.sessions.map((s) => ({ + id: s.id, + start: new Date(s.startTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), + end: s.endTime + ? new Date(s.endTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + : 'running', + duration: formatDuration((( s.endTime ?? now) - s.startTime) / 1000), + })), + }; + }); + respond({ + success: true, + data: { date, grandTotal: formatDuration(grandTotalSeconds), items: formatted }, + }); break; } @@ -592,30 +804,98 @@ export const OzwellWidget: React.FC = () => { } /** - * Compound action: create a ticket (if no ticketId provided), create a work item - * for today, and immediately start the timer. This is the single tool the AI should - * call when the user says "start working on X". + * Start a timer for an existing ticket. + * + * Rules enforced here (per product requirements): + * 1. Never creates a ticket — use create_ticket first if needed. + * 2. Ticket must belong to the currently selected team. + * 3. User must be clocked in to the currently selected team. + * 4. Reuses an existing work item for today if one exists (idempotent). */ case 'start_ticket_timer': { - if (!ctx.selectedTeamId) { - respond({ success: false, error: 'No team selected' }); + const teamId = ctx.selectedTeamId; + const teamName = ctx.selectedTeam?.name ?? 'the selected team'; + + if (!teamId) { + respond({ + success: false, + error: 'No team selected. Use switch_team to select a team first.', + }); return; } - // Resolve ticketId — caller may pass an existing id or a title to create + + // ── 1. Resolve ticketId (lookup only — never create) ────────────── let ticketId = String(args.ticketId ?? ''); const ticketTitle = String(args.title ?? ''); - if (!ticketId) { - if (!ticketTitle) { - respond({ success: false, error: 'Provide ticketId or title' }); + + if (!ticketId && !ticketTitle) { + respond({ success: false, error: 'Provide ticketId or title to identify the ticket.' }); + return; + } + + // Fetch all tickets for the current team + const teamTickets = await ticketApi.getTickets(teamId); + + if (ticketId) { + // Validate the provided ID belongs to the current team + const belongs = teamTickets.some((t) => t.id === ticketId); + if (!belongs) { + respond({ + success: false, + error: + `Ticket ${ticketId} does not belong to "${teamName}". ` + + `Use switch_team to switch to the correct team, or get_tickets to list tickets for this team.`, + }); return; } - const newTicket = await ticketApi.createTicket({ teamId: ctx.selectedTeamId, title: ticketTitle }); - ticketId = newTicket.id; + } else { + // Find by title (case-insensitive) + const match = teamTickets.find( + (t) => t.title.toLowerCase() === ticketTitle.toLowerCase(), + ); + if (!match) { + respond({ + success: false, + error: + `No ticket titled "${ticketTitle}" found in "${teamName}". ` + + `Use get_tickets to list available tickets, or create_ticket if you want to create a new one.`, + }); + return; + } + ticketId = match.id; + } + + // ── 2. Verify user is clocked in to this team ───────────────────── + if (!ctx.activeClockEvent) { + respond({ + success: false, + error: + `You are not clocked in. Use clock_in to clock in to "${teamName}" first.`, + }); + return; } + if (ctx.activeClockEvent.teamId !== teamId) { + const clockedTeam = ctx.teams.find((t) => t.id === ctx.activeClockEvent?.teamId); + respond({ + success: false, + error: + `You are clocked in to "${clockedTeam?.name ?? ctx.activeClockEvent.teamId}" but the ticket belongs to "${teamName}". ` + + `Use switch_team to switch to the correct team, then clock_out and clock_in again.`, + }); + return; + } + + // ── 3. Get or create a work item for today ──────────────────────── const today = new Date().toLocaleDateString('en-CA'); - const entry = await timerApi.createEntry({ ticketId, date: today }); + const todayEntries = await timerApi.getDay(today); + const existingDayEntry = todayEntries.find((de) => de.entry.ticketId === ticketId); + const entry = existingDayEntry + ? existingDayEntry.entry + : await timerApi.createEntry({ ticketId, date: today }); + + // ── 4. Start the timer ──────────────────────────────────────────── const session = await timerApi.startSession(entry.id); - respond({ success: true, data: { ticket: { id: ticketId }, workItem: entry, session } }); + respond({ success: true, data: { workItem: entry, session } }); break; } diff --git a/src/features/ai/ozwell-tools.ts b/src/features/ai/ozwell-tools.ts index 22800027..96e086a5 100644 --- a/src/features/ai/ozwell-tools.ts +++ b/src/features/ai/ozwell-tools.ts @@ -96,7 +96,10 @@ export const OZWELL_TOOLS: OzwellTool[] = [ type: 'function', function: { name: 'clock_in', - description: 'Clock the user in for the currently selected team.', + description: + 'Clock the user in to the currently selected team. ' + + 'Does NOT switch teams — call switch_team first if the user wants a different team. ' + + 'Only call this when the user explicitly asks to clock in.', parameters: { type: 'object', properties: {}, required: [] }, }, }, @@ -152,12 +155,19 @@ export const OZWELL_TOOLS: OzwellTool[] = [ type: 'function', function: { name: 'create_ticket', - description: 'Create a new ticket in the currently selected team.', + description: + 'Create a new ticket in the currently selected team. Only call this when the user EXPLICITLY asks to create a new ticket. Do NOT call this as part of starting a timer. ' + + 'If the user provides a GitHub issue or PR URL, pass it as the title — the system will auto-fetch the real title and body from GitHub.', parameters: { type: 'object', properties: { - title: { type: 'string', description: 'Ticket title' }, - description: { type: 'string', description: 'Optional ticket description' }, + title: { + type: 'string', + description: + 'Ticket title. If this is a GitHub issue/PR URL (e.g. https://github.com/org/repo/issues/1), the real title and description will be fetched automatically.', + }, + description: { type: 'string', description: 'Optional description. Omit if a GitHub URL is provided — the body will be fetched automatically.' }, + github: { type: 'string', description: 'GitHub issue or PR URL to link to this ticket (optional, only provide if the title is NOT already a URL).' }, }, required: ['title'], }, @@ -206,6 +216,24 @@ export const OZWELL_TOOLS: OzwellTool[] = [ }, // ── Teams ─────────────────────────────────────────────────────────────────── + { + type: 'function', + function: { + name: 'switch_team', + description: + 'Switch the active team context. Use this whenever the user asks to switch teams, change teams, or select a different team. ' + + 'This does NOT clock the user in or out — it only changes which team is active in the app. ' + + 'Responds with alreadySelected: true if the user is already on the requested team.', + parameters: { + type: 'object', + properties: { + teamId: { type: 'string', description: 'ID of the team to switch to (preferred)' }, + name: { type: 'string', description: 'Name of the team to switch to (used if teamId is unknown)' }, + }, + required: [], + }, + }, + }, { type: 'function', function: { @@ -251,4 +279,144 @@ export const OZWELL_TOOLS: OzwellTool[] = [ }, }, }, + + // ── Clock Sessions ─────────────────────────────────────────────────────────── + { + type: 'function', + function: { + name: 'get_clock_sessions', + description: + 'Get clock-in/clock-out sessions (team attendance records) for a date range. Use this to answer questions about how long the user worked, when they clocked in/out, or to summarise work across multiple days. Returns sessions with start/end times, duration, team name, and a summary total. IMPORTANT: To query a specific team, pass team_id or team_name directly — do NOT call switch_team first.', + parameters: { + type: 'object', + properties: { + start_date: { + type: 'string', + description: 'Start of date range in YYYY-MM-DD format. Defaults to today if omitted.', + }, + end_date: { + type: 'string', + description: + 'End of date range in YYYY-MM-DD format (inclusive). Defaults to start_date if omitted.', + }, + team_id: { + type: 'string', + description: 'Filter results to a specific team by ID. Omit to use the currently selected team.', + }, + team_name: { + type: 'string', + description: 'Filter results to a specific team by name (case-insensitive partial match). Use this when the user mentions a team by name.', + }, + }, + required: [], + }, + }, + }, + + // ── Work / Timers ─────────────────────────────────────────────────────────── + { + type: 'function', + function: { + name: 'get_work_items', + description: + 'Get the list of work items (ticket timer rows) for a given date. Returns each work item with its ticket ID, title, and any timer sessions. NOTE: This only returns ticket-level timers, not clock-in/out sessions — use get_clock_sessions for attendance history.', + parameters: { + type: 'object', + properties: { + date: { + type: 'string', + description: 'Local date in YYYY-MM-DD format. Defaults to today if omitted.', + }, + }, + required: [], + }, + }, + }, + { + type: 'function', + function: { + name: 'create_work_item', + description: + 'Add an existing ticket to the Work page for a given date, creating a timer row without starting the clock. The ticket must already exist in the selected team.', + parameters: { + type: 'object', + properties: { + ticketId: { type: 'string', description: 'ID of the existing ticket to add' }, + date: { + type: 'string', + description: 'Local date in YYYY-MM-DD format. Defaults to today if omitted.', + }, + note: { type: 'string', description: 'Optional note to attach to the work item' }, + }, + required: ['ticketId'], + }, + }, + }, + { + type: 'function', + function: { + name: 'start_work_timer', + description: + 'Start a timer for an existing work item (identified by its workItemId). The user must already be clocked in. Use start_ticket_timer instead if you only have a ticket title or ID.', + parameters: { + type: 'object', + properties: { + workItemId: { type: 'string', description: 'Work item (entry) ID to start the timer for' }, + }, + required: ['workItemId'], + }, + }, + }, + { + type: 'function', + function: { + name: 'stop_work_timer', + description: 'Stop a currently running timer session by its session ID.', + parameters: { + type: 'object', + properties: { + sessionId: { type: 'string', description: 'Running timer session ID to stop' }, + }, + required: ['sessionId'], + }, + }, + }, + { + type: 'function', + function: { + name: 'get_running_timer', + description: + "Get the user's currently running timer session, or null if no timer is active. Returns the session with its workItemId, startTime, and elapsed seconds.", + parameters: { type: 'object', properties: {}, required: [] }, + }, + }, + { + type: 'function', + function: { + name: 'start_ticket_timer', + description: + "Start a timer for a specific ticket in the Work page. " + + "IMPORTANT RULES: " + + "(1) This tool NEVER creates a ticket — if the ticket doesn't exist, tell the user and offer to use create_ticket. " + + "(2) The ticket must belong to the currently selected team — if not, use switch_team first. " + + "(3) The user must be clocked in to the selected team before a timer can start. " + + "(4) A work item row for today is automatically created if one doesn't exist. " + + "Recommended flow: get_teams → switch_team → get_clock_status → clock_in (if needed) → get_tickets → start_ticket_timer.", + parameters: { + type: 'object', + properties: { + ticketId: { + type: 'string', + description: 'ID of the ticket to time (preferred). Must belong to the selected team.', + }, + title: { + type: 'string', + description: + 'Title of the ticket to look up (used when ticketId is unknown). Must match an existing ticket in the selected team.', + }, + }, + required: [], + }, + }, + }, ]; diff --git a/src/features/tickets/TicketsPage.tsx b/src/features/tickets/TicketsPage.tsx index d35283e9..93951396 100644 --- a/src/features/tickets/TicketsPage.tsx +++ b/src/features/tickets/TicketsPage.tsx @@ -870,7 +870,7 @@ export const TicketsPage: React.FC = () => { label="Team" activeLabel={activeTeamLabel} open={openFilterMenu === 'team'} - onOpenChange={(open) => setOpenFilterMenu(open ? 'team' : null)} + onOpenChange={(open) => setOpenFilterMenu((prev) => (open ? 'team' : prev === 'team' ? null : prev))} > setTeamFilter(null)} @@ -894,7 +894,7 @@ export const TicketsPage: React.FC = () => { label="Priority" activeLabel={activePriorityLabel} open={openFilterMenu === 'priority'} - onOpenChange={(open) => setOpenFilterMenu(open ? 'priority' : null)} + onOpenChange={(open) => setOpenFilterMenu((prev) => (open ? 'priority' : prev === 'priority' ? null : prev))} > setPriorityFilter(null)} @@ -918,7 +918,7 @@ export const TicketsPage: React.FC = () => { activeLabel={activeStatusDetailLabel} placement="bottom-end" open={openFilterMenu === 'status'} - onOpenChange={(open) => setOpenFilterMenu(open ? 'status' : null)} + onOpenChange={(open) => setOpenFilterMenu((prev) => (open ? 'status' : prev === 'status' ? null : prev))} > setStatusDetailFilter(null)} @@ -946,7 +946,7 @@ export const TicketsPage: React.FC = () => { activeLabel={activeAssigneeLabel} placement="bottom-end" open={openFilterMenu === 'assignee'} - onOpenChange={(open) => setOpenFilterMenu(open ? 'assignee' : null)} + onOpenChange={(open) => setOpenFilterMenu((prev) => (open ? 'assignee' : prev === 'assignee' ? null : prev))} > setAssigneeFilter(null)} diff --git a/src/lib/api.ts b/src/lib/api.ts index 37b2c7e1..b64117cf 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -98,8 +98,10 @@ async function request(path: string, options: RequestInit = {}): Pr const hasBody = options.body != null; const token = sessionToken.get(); const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 8000); - // Destructure headers out of options so that ...restOptions below does not + const timeoutId = setTimeout( + () => controller.abort(new Error('Request timed out. Please check your connection and try again.')), + 8000, + ); // overwrite the merged headers object (which would drop Authorization). const { headers: optHeaders, ...restOptions } = options; try { @@ -135,7 +137,10 @@ async function request(path: string, options: RequestInit = {}): Pr /** fetch() with an 8-second abort timeout — prevents indefinite hangs on slow connections. */ async function timedFetch(url: string, options: RequestInit = {}): Promise { const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 8000); + const timeoutId = setTimeout( + () => controller.abort(new Error('Request timed out. Please check your connection and try again.')), + 8000, + ); try { return await fetch(url, { signal: controller.signal, ...options }); } finally { From bd43df0d7b739adbdc7c6b8c37ea90d43dc17351 Mon Sep 17 00:00:00 2001 From: Poonam Dharamkar Date: Mon, 25 May 2026 13:23:49 -0400 Subject: [PATCH 4/7] feat: add functionality to hide "(no response)" bubbles in OzwellWidget; implement work refetch event listener in WorkPage; create jerry-agent.yaml for AI assistant configuration Co-authored-by: Copilot --- jerry-agent.yaml | 352 +++++++++++++++++++++++++++++++ src/features/ai/OzwellWidget.tsx | 27 ++- src/features/timers/WorkPage.tsx | 6 + 3 files changed, 383 insertions(+), 2 deletions(-) create mode 100644 jerry-agent.yaml diff --git a/jerry-agent.yaml b/jerry-agent.yaml new file mode 100644 index 00000000..75d925ad --- /dev/null +++ b/jerry-agent.yaml @@ -0,0 +1,352 @@ +name: Jerry +description: Huddle AI assistant — clocks, tickets, work timers, and team management. +instructions: | + You are Jerry, the AI assistant built into Huddle. You help users manage their + daily work: clocking in and out, tracking time on tickets, managing teams, and + navigating the app. You are concise, friendly, and always act on the user's explicit + intent — never assume or over-act. + + CRITICAL — always include a short text message in EVERY response, even when calling + tools. A response with tool calls and no text shows as "(no response)" to the user. + Before each set of tool calls write one brief line, e.g. "Switching to Mobile test and + grabbing your tickets…" or "Clocking you in now…". Never send a tool-call-only turn. + + Your session context is kept up to date automatically. When answering simple questions + about the user's current team or clock status, call get_current_team or get_clock_status + directly — they return live values instantly and require no extra setup. Never guess or + invent team names; always use the value returned by the tool. +behavior: + tone: concise, friendly, action-oriented + always_navigate: true + confirm_destructive: true + rules: + - Always include a short text message alongside every tool call — never send a tool-call-only turn (it shows as "(no response)"). + - '"Switch to [team]" or "change team" → call switch_team ONLY. Never call clock_in as part of a team switch.' + - Only call clock_in when the user explicitly says "clock in", "start my day", or equivalent. + - Only call clock_out when the user explicitly says "clock out", "end my day", or equivalent. + - Never call create_ticket inside or before start_ticket_timer. If the ticket does not exist, tell the user and offer to use create_ticket separately. + - Always confirm with the user before deleting anything (tickets, teams, timesheet entries). + - After taking an action, navigate to the relevant page so the user can see the result. + - Before clocking in or starting a timer, verify the correct team is already selected. Use switch_team if needed. + - If no team is selected when the user asks to clock in, ask which team they want first — do not guess. + - Never infer that the user wants to clock in just because they switched teams. + - If a requested ticket does not belong to the currently selected team, use switch_team before proceeding. +tools: + - name: navigate + description: Navigate the user to a different page in the TimeHuddle app. Use this when the user asks to go somewhere or open a section. + inputSchema: + type: object + properties: + path: + type: string + description: "App route path, e.g. /app/dashboard, /app/clock, /app/tickets, /app/work, /app/timesheet, /app/teams, /app/messages, /app/notifications, /app/activity, /app/settings" + required: + - path + + - name: get_current_user + description: Get the currently logged-in user's name, email, and ID. + inputSchema: + type: object + properties: {} + required: [] + + - name: get_current_page + description: Get the current page/route the user is viewing. + inputSchema: + type: object + properties: {} + required: [] + + - name: get_current_team + description: Get the currently selected team's name, ID, and member count. Call this when the user asks which team they are on — always use the returned name, never guess. + inputSchema: + type: object + properties: {} + required: [] + + - name: get_teams + description: List all teams the user belongs to. + inputSchema: + type: object + properties: {} + required: [] + + - name: get_clock_status + description: Check whether the user is currently clocked in or out and when they started. Returns live clock state — always call this before clock_in or clock_out to verify current status. + inputSchema: + type: object + properties: {} + required: [] + + - name: clock_in + description: Clock the user in to the currently selected team. Does NOT switch teams — call switch_team first if the user wants a different team. Only call this when the user explicitly asks to clock in. + inputSchema: + type: object + properties: {} + required: [] + + - name: clock_out + description: Clock the user out for the currently selected team. + inputSchema: + type: object + properties: {} + required: [] + + - name: update_timesheet_entry + description: Update the start or end time of a timesheet (clock) entry by its ID. + inputSchema: + type: object + properties: + id: + type: string + description: Clock event ID to update + startTime: + type: string + description: New start time as ISO 8601 string (optional) + endTime: + type: string + description: New end time as ISO 8601 string (optional) + required: + - id + + - name: delete_timesheet_entry + description: Delete a timesheet (clock) entry by its ID. This cannot be undone. + inputSchema: + type: object + properties: + id: + type: string + description: Clock event ID to delete + required: + - id + + - name: get_tickets + description: Get all tickets for the currently selected team. + inputSchema: + type: object + properties: {} + required: [] + + - name: create_ticket + description: Create a new ticket in the currently selected team. Only call this when the user EXPLICITLY asks to create a new ticket. Do NOT call this as part of starting a timer. If the user provides a GitHub issue or PR URL, pass it as the title — the system will auto-fetch the real title and body from GitHub. + inputSchema: + type: object + properties: + title: + type: string + description: "Ticket title. If this is a GitHub issue/PR URL (e.g. https://github.com/org/repo/issues/1), the real title and description will be fetched automatically." + description: + type: string + description: Optional description. Omit if a GitHub URL is provided — the body will be fetched automatically. + github: + type: string + description: GitHub issue or PR URL to link to this ticket (optional, only if title is NOT already a URL). + required: + - title + + - name: update_ticket + description: Update a ticket's title, description, status, or priority. Only provide the fields you want to change. + inputSchema: + type: object + properties: + id: + type: string + description: Ticket ID to update + title: + type: string + description: New title (optional) + description: + type: string + description: New description (optional) + status: + type: string + description: New status (optional) + enum: + - open + - in-progress + - done + - blocked + priority: + type: string + description: New priority (optional) + enum: + - low + - medium + - high + - critical + required: + - id + + - name: delete_ticket + description: Delete a ticket by its ID. This cannot be undone — confirm with the user first. + inputSchema: + type: object + properties: + id: + type: string + description: Ticket ID to delete + required: + - id + + - name: switch_team + description: Switch the active team context. Call this whenever the user asks to switch, change, or go to a different team. This does NOT clock the user in or out — it only changes which team is active in the app. Responds with alreadySelected true if the user is already on the requested team. + inputSchema: + type: object + properties: + teamId: + type: string + description: ID of the team to switch to (preferred) + name: + type: string + description: Name of the team to switch to (case-insensitive, used if teamId is unknown) + required: [] + + - name: create_team + description: Create a new team. + inputSchema: + type: object + properties: + name: + type: string + description: Team name + description: + type: string + description: Optional team description + required: + - name + + - name: update_team + description: Rename a team by its ID. + inputSchema: + type: object + properties: + id: + type: string + description: Team ID to rename + name: + type: string + description: New team name + required: + - id + - name + + - name: delete_team + description: Delete a team by its ID. This is destructive and cannot be undone — always confirm with the user first. + inputSchema: + type: object + properties: + id: + type: string + description: Team ID to delete + required: + - id + + - name: get_clock_sessions + description: > + Get clock-in/clock-out sessions (team attendance records) for a date range. + Use this to answer questions about how long the user worked, when they clocked in/out, + or to summarise work across multiple days. + Returns sessions with start/end times, duration, team name, and a summary total. + IMPORTANT: To query a specific team, pass team_id or team_name directly — do NOT + call switch_team first. switch_team changes the app context and is not needed for history queries. + If the user mentions a team by name, pass it as team_name and omit switch_team entirely. + inputSchema: + type: object + properties: + start_date: + type: string + description: "Start of date range in YYYY-MM-DD format. Defaults to today if omitted." + end_date: + type: string + description: "End of date range in YYYY-MM-DD format (inclusive). Defaults to start_date if omitted." + team_id: + type: string + description: "Filter results to a specific team by ID. Omit to use the currently selected team." + team_name: + type: string + description: "Filter results to a specific team by name (case-insensitive partial match). Use this when the user mentions a team by name." + required: [] + + - name: get_work_items + description: > + Get the list of work items (ticket timer rows) for a given date. Returns each + work item with its ticket ID, title, and any timer sessions. + NOTE: This only covers ticket-level timers. For clock-in/out attendance history + (e.g. "what did I work on", "how long did I work"), use get_clock_sessions instead. + inputSchema: + type: object + properties: + date: + type: string + description: Local date in YYYY-MM-DD format. Defaults to today if omitted. + required: [] + + - name: create_work_item + description: Add an existing ticket to the Work page for a given date, creating a timer row without starting the clock. The ticket must already exist in the selected team. + inputSchema: + type: object + properties: + ticketId: + type: string + description: ID of the existing ticket to add + date: + type: string + description: Local date in YYYY-MM-DD format. Defaults to today if omitted. + note: + type: string + description: Optional note to attach to the work item + required: + - ticketId + + - name: start_work_timer + description: Start a timer for an existing work item (identified by its workItemId). The user must already be clocked in. Use start_ticket_timer instead if you only have a ticket title or ID. + inputSchema: + type: object + properties: + workItemId: + type: string + description: Work item (entry) ID to start the timer for + required: + - workItemId + + - name: stop_work_timer + description: Stop a currently running timer session by its session ID. + inputSchema: + type: object + properties: + sessionId: + type: string + description: Running timer session ID to stop + required: + - sessionId + + - name: get_running_timer + description: Get the user's currently running timer session, or null if no timer is active. Returns the session with its workItemId, startTime, and elapsed seconds. + inputSchema: + type: object + properties: {} + required: [] + + - name: start_ticket_timer + description: > + Start a timer for a specific ticket in the Work page. + IMPORTANT RULES: + (1) This tool NEVER creates a ticket — if the ticket does not exist, tell the user and offer create_ticket. + (2) The ticket must belong to the currently selected team — if not, use switch_team first. + (3) The user must be clocked in to the selected team before a timer can start. + (4) A work item row for today is automatically created if one does not exist. + Optimised flow (minimise rounds — always write a text line before each round): + Round 1: get_clock_status + switch_team (if needed) + get_tickets all in parallel — write "Switching to [team] and loading tickets…" + Round 2: clock_in (only if not already clocked in per get_clock_status result) — write "Clocking you in to [team]…" + Round 3: start_ticket_timer — write "Starting your timer…" + If the user is already on the right team AND already clocked in, call get_tickets alone then start_ticket_timer. + inputSchema: + type: object + properties: + ticketId: + type: string + description: ID of the ticket to time (preferred). Must belong to the selected team. + title: + type: string + description: Title of the ticket to look up (used when ticketId is unknown). Must match an existing ticket in the selected team. + required: [] diff --git a/src/features/ai/OzwellWidget.tsx b/src/features/ai/OzwellWidget.tsx index 8e06f3e6..f29ffeb4 100644 --- a/src/features/ai/OzwellWidget.tsx +++ b/src/features/ai/OzwellWidget.tsx @@ -155,6 +155,18 @@ function injectJerryButtonContent() { `; } +/** Hide tool-call-only turns that the Ozwell platform renders as "(no response)". */ +function hideNoResponseBubbles(root: HTMLElement) { + for (const el of Array.from(root.querySelectorAll('*'))) { + if (el.textContent?.trim() === '(no response)') { + const parent = el.parentElement; + // Skip inner elements — only hide the outermost wrapper for this text + if (parent && parent !== root && parent.textContent?.trim() === '(no response)') continue; + el.style.setProperty('display', 'none', 'important'); + } + } +} + /** Override the loader's full-screen mobile styles — bottom-sheet pattern. */ function injectMobileOverride() { if (document.getElementById(MOBILE_OVERRIDE_STYLE_ID)) return; @@ -316,7 +328,15 @@ export const OzwellWidget: React.FC = () => { return () => document.removeEventListener('ozwell-chat-ready', onReady); }, []); - // ── Effect 3: sync live context to widget ───────────────────────────────── + // ── Effect 3: hide "(no response)" tool-call bubbles ────────────────────── + useEffect(() => { + const observer = new MutationObserver(() => hideNoResponseBubbles(document.body)); + observer.observe(document.body, { childList: true, subtree: true, characterData: true }); + hideNoResponseBubbles(document.body); + return () => observer.disconnect(); + }, []); + + // ── Effect 4: sync live context to widget ───────────────────────────────── useEffect(() => { if (!window.OzwellChat?.updateContext) return; window.OzwellChat.updateContext({ @@ -338,7 +358,7 @@ export const OzwellWidget: React.FC = () => { }); }, [user, pathname, selectedTeam, selectedTeamId, activeClockEvent, teams]); - // ── Effect 4: tool call handler ─────────────────────────────────────────── + // ── Effect 5: tool call handler ─────────────────────────────────────────── const handleToolCall = useCallback((e: Event) => { const { name, arguments: args, respond } = (e as CustomEvent).detail; const ctx = ctxRef.current; @@ -782,6 +802,7 @@ export const OzwellWidget: React.FC = () => { return; } const result = await timerApi.startSession(entryId); + window.dispatchEvent(new Event('work:refetch')); respond({ success: true, data: result }); break; } @@ -793,6 +814,7 @@ export const OzwellWidget: React.FC = () => { return; } const stopped = await timerApi.stopSession(sessionId); + window.dispatchEvent(new Event('work:refetch')); respond({ success: true, data: stopped }); break; } @@ -895,6 +917,7 @@ export const OzwellWidget: React.FC = () => { // ── 4. Start the timer ──────────────────────────────────────────── const session = await timerApi.startSession(entry.id); + window.dispatchEvent(new Event('work:refetch')); respond({ success: true, data: { workItem: entry, session } }); break; } diff --git a/src/features/timers/WorkPage.tsx b/src/features/timers/WorkPage.tsx index 37223da4..f47a0e5f 100644 --- a/src/features/timers/WorkPage.tsx +++ b/src/features/timers/WorkPage.tsx @@ -231,6 +231,12 @@ export const WorkPage: React.FC = () => { void fetchDay(); }, [fetchDay]); + useEffect(() => { + const handler = () => { void fetchDay(); void fetchWeekTotals(); }; + window.addEventListener('work:refetch', handler); + return () => window.removeEventListener('work:refetch', handler); + }, [fetchDay, fetchWeekTotals]); + useEffect(() => { const previousClockedIn = previousClockedInRef.current; previousClockedInRef.current = isClockedIn; From 2818e8f43430883ad4ad0b621f01ca5bc7e6ead9 Mon Sep 17 00:00:00 2001 From: Poonam Dharamkar Date: Mon, 25 May 2026 13:29:02 -0400 Subject: [PATCH 5/7] Fix CI: format all files, remove unused eslint-disable --- jerry-agent.yaml | 12 ++--- src/features/ai/OzwellWidget.tsx | 72 ++++++++++++++++++++-------- src/features/ai/ozwell-tools.ts | 46 +++++++++++++----- src/features/tickets/TicketsPage.tsx | 20 ++++++-- src/features/timers/WorkPage.tsx | 5 +- src/lib/api.ts | 6 ++- 6 files changed, 114 insertions(+), 47 deletions(-) diff --git a/jerry-agent.yaml b/jerry-agent.yaml index 75d925ad..e2f88efe 100644 --- a/jerry-agent.yaml +++ b/jerry-agent.yaml @@ -39,7 +39,7 @@ tools: properties: path: type: string - description: "App route path, e.g. /app/dashboard, /app/clock, /app/tickets, /app/work, /app/timesheet, /app/teams, /app/messages, /app/notifications, /app/activity, /app/settings" + description: 'App route path, e.g. /app/dashboard, /app/clock, /app/tickets, /app/work, /app/timesheet, /app/teams, /app/messages, /app/notifications, /app/activity, /app/settings' required: - path @@ -134,7 +134,7 @@ tools: properties: title: type: string - description: "Ticket title. If this is a GitHub issue/PR URL (e.g. https://github.com/org/repo/issues/1), the real title and description will be fetched automatically." + description: 'Ticket title. If this is a GitHub issue/PR URL (e.g. https://github.com/org/repo/issues/1), the real title and description will be fetched automatically.' description: type: string description: Optional description. Omit if a GitHub URL is provided — the body will be fetched automatically. @@ -255,16 +255,16 @@ tools: properties: start_date: type: string - description: "Start of date range in YYYY-MM-DD format. Defaults to today if omitted." + description: 'Start of date range in YYYY-MM-DD format. Defaults to today if omitted.' end_date: type: string - description: "End of date range in YYYY-MM-DD format (inclusive). Defaults to start_date if omitted." + description: 'End of date range in YYYY-MM-DD format (inclusive). Defaults to start_date if omitted.' team_id: type: string - description: "Filter results to a specific team by ID. Omit to use the currently selected team." + description: 'Filter results to a specific team by ID. Omit to use the currently selected team.' team_name: type: string - description: "Filter results to a specific team by name (case-insensitive partial match). Use this when the user mentions a team by name." + description: 'Filter results to a specific team by name (case-insensitive partial match). Use this when the user mentions a team by name.' required: [] - name: get_work_items diff --git a/src/features/ai/OzwellWidget.tsx b/src/features/ai/OzwellWidget.tsx index f29ffeb4..e7b0725e 100644 --- a/src/features/ai/OzwellWidget.tsx +++ b/src/features/ai/OzwellWidget.tsx @@ -18,11 +18,7 @@ import { useCallback, useEffect, useRef } from 'react'; import { clockApi, teamApi, ticketApi, timerApi } from '../../lib/api'; import { useSession } from '../../lib/useSession'; -import { - createTicketFromGithub, - fetchGithubIssue, - isGithubIssueUrl, -} from '../tickets/githubIssue'; +import { createTicketFromGithub, fetchGithubIssue, isGithubIssueUrl } from '../tickets/githubIssue'; import { useTeam } from '../../lib/TeamContext'; import { useRouter } from '../../ui/router'; @@ -43,8 +39,7 @@ declare global { OzwellChatConfig?: { apiKey: string; debug?: boolean; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - tools?: any[]; + tools?: unknown[]; }; OzwellChat?: { open: () => void; @@ -254,7 +249,8 @@ function injectMobileOverride() { export const OzwellWidget: React.FC = () => { const { user } = useSession(); const { pathname, navigate } = useRouter(); - const { selectedTeam, selectedTeamId, teams, activeClockEvent, refetchClock, setSelectedTeamId } = useTeam(); + const { selectedTeam, selectedTeamId, teams, activeClockEvent, refetchClock, setSelectedTeamId } = + useTeam(); // Keep a stable ref to mutable context so the tool handler always has fresh values // without needing to re-register the listener on every render. @@ -281,7 +277,17 @@ export const OzwellWidget: React.FC = () => { navigate, setSelectedTeamId, }; - }, [user, pathname, selectedTeam, selectedTeamId, teams, activeClockEvent, refetchClock, navigate, setSelectedTeamId]); + }, [ + user, + pathname, + selectedTeam, + selectedTeamId, + teams, + activeClockEvent, + refetchClock, + navigate, + setSelectedTeamId, + ]); // ── Effect 1: inject loader script once ─────────────────────────────────── useEffect(() => { @@ -353,7 +359,10 @@ export const OzwellWidget: React.FC = () => { ? (teams.find((t) => t.id === activeClockEvent.teamId)?.name ?? null) : null, clockedInSince: activeClockEvent - ? new Date(activeClockEvent.startTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + ? new Date(activeClockEvent.startTime).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + }) : null, }); }, [user, pathname, selectedTeam, selectedTeamId, activeClockEvent, teams]); @@ -450,8 +459,15 @@ export const OzwellWidget: React.FC = () => { const totalSeconds = filteredSessions.reduce((sum, s) => sum + getWorkSeconds(s), 0); const sessions = filteredSessions.map((s) => ({ id: s.id, - date: new Date(s.startTime).toLocaleDateString([], { year: 'numeric', month: 'short', day: 'numeric' }), - clockIn: new Date(s.startTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), + date: new Date(s.startTime).toLocaleDateString([], { + year: 'numeric', + month: 'short', + day: 'numeric', + }), + clockIn: new Date(s.startTime).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + }), clockOut: s.endTime ? new Date(s.endTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : 'still clocked in', @@ -477,7 +493,8 @@ export const OzwellWidget: React.FC = () => { case 'get_clock_status': { if (ctx.activeClockEvent) { - const elapsedSeconds = (Date.now() - new Date(ctx.activeClockEvent.startTime).getTime()) / 1000; + const elapsedSeconds = + (Date.now() - new Date(ctx.activeClockEvent.startTime).getTime()) / 1000; respond({ success: true, data: { @@ -527,7 +544,8 @@ export const OzwellWidget: React.FC = () => { return; } const updates: { startTime?: number; endTime?: number | null } = {}; - if (args.startTime != null) updates.startTime = new Date(String(args.startTime)).getTime(); + if (args.startTime != null) + updates.startTime = new Date(String(args.startTime)).getTime(); if (args.endTime != null) updates.endTime = new Date(String(args.endTime)).getTime(); const updated = await clockApi.updateTimes(entryId, updates); ctx.refetchClock(); @@ -768,11 +786,17 @@ export const OzwellWidget: React.FC = () => { total: formatDuration(totalSeconds), sessions: e.sessions.map((s) => ({ id: s.id, - start: new Date(s.startTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), + start: new Date(s.startTime).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + }), end: s.endTime - ? new Date(s.endTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + ? new Date(s.endTime).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + }) : 'running', - duration: formatDuration((( s.endTime ?? now) - s.startTime) / 1000), + duration: formatDuration(((s.endTime ?? now) - s.startTime) / 1000), })), }; }); @@ -790,7 +814,11 @@ export const OzwellWidget: React.FC = () => { return; } const date = String(args.date ?? new Date().toLocaleDateString('en-CA')); - const entry = await timerApi.createEntry({ ticketId, date, note: args.note as string | undefined }); + const entry = await timerApi.createEntry({ + ticketId, + date, + note: args.note as string | undefined, + }); respond({ success: true, data: entry }); break; } @@ -851,7 +879,10 @@ export const OzwellWidget: React.FC = () => { const ticketTitle = String(args.title ?? ''); if (!ticketId && !ticketTitle) { - respond({ success: false, error: 'Provide ticketId or title to identify the ticket.' }); + respond({ + success: false, + error: 'Provide ticketId or title to identify the ticket.', + }); return; } @@ -891,8 +922,7 @@ export const OzwellWidget: React.FC = () => { if (!ctx.activeClockEvent) { respond({ success: false, - error: - `You are not clocked in. Use clock_in to clock in to "${teamName}" first.`, + error: `You are not clocked in. Use clock_in to clock in to "${teamName}" first.`, }); return; } diff --git a/src/features/ai/ozwell-tools.ts b/src/features/ai/ozwell-tools.ts index 96e086a5..cc1feedd 100644 --- a/src/features/ai/ozwell-tools.ts +++ b/src/features/ai/ozwell-tools.ts @@ -120,7 +120,10 @@ export const OZWELL_TOOLS: OzwellTool[] = [ type: 'object', properties: { id: { type: 'string', description: 'Clock event ID to update' }, - startTime: { type: 'string', description: 'New start time as ISO 8601 string (optional)' }, + startTime: { + type: 'string', + description: 'New start time as ISO 8601 string (optional)', + }, endTime: { type: 'string', description: 'New end time as ISO 8601 string (optional)' }, }, required: ['id'], @@ -166,8 +169,16 @@ export const OZWELL_TOOLS: OzwellTool[] = [ description: 'Ticket title. If this is a GitHub issue/PR URL (e.g. https://github.com/org/repo/issues/1), the real title and description will be fetched automatically.', }, - description: { type: 'string', description: 'Optional description. Omit if a GitHub URL is provided — the body will be fetched automatically.' }, - github: { type: 'string', description: 'GitHub issue or PR URL to link to this ticket (optional, only provide if the title is NOT already a URL).' }, + description: { + type: 'string', + description: + 'Optional description. Omit if a GitHub URL is provided — the body will be fetched automatically.', + }, + github: { + type: 'string', + description: + 'GitHub issue or PR URL to link to this ticket (optional, only provide if the title is NOT already a URL).', + }, }, required: ['title'], }, @@ -204,7 +215,8 @@ export const OZWELL_TOOLS: OzwellTool[] = [ type: 'function', function: { name: 'delete_ticket', - description: 'Delete a ticket by its ID. This cannot be undone — confirm with the user first.', + description: + 'Delete a ticket by its ID. This cannot be undone — confirm with the user first.', parameters: { type: 'object', properties: { @@ -228,7 +240,10 @@ export const OZWELL_TOOLS: OzwellTool[] = [ type: 'object', properties: { teamId: { type: 'string', description: 'ID of the team to switch to (preferred)' }, - name: { type: 'string', description: 'Name of the team to switch to (used if teamId is unknown)' }, + name: { + type: 'string', + description: 'Name of the team to switch to (used if teamId is unknown)', + }, }, required: [], }, @@ -301,11 +316,13 @@ export const OZWELL_TOOLS: OzwellTool[] = [ }, team_id: { type: 'string', - description: 'Filter results to a specific team by ID. Omit to use the currently selected team.', + description: + 'Filter results to a specific team by ID. Omit to use the currently selected team.', }, team_name: { type: 'string', - description: 'Filter results to a specific team by name (case-insensitive partial match). Use this when the user mentions a team by name.', + description: + 'Filter results to a specific team by name (case-insensitive partial match). Use this when the user mentions a team by name.', }, }, required: [], @@ -361,7 +378,10 @@ export const OZWELL_TOOLS: OzwellTool[] = [ parameters: { type: 'object', properties: { - workItemId: { type: 'string', description: 'Work item (entry) ID to start the timer for' }, + workItemId: { + type: 'string', + description: 'Work item (entry) ID to start the timer for', + }, }, required: ['workItemId'], }, @@ -395,13 +415,13 @@ export const OZWELL_TOOLS: OzwellTool[] = [ function: { name: 'start_ticket_timer', description: - "Start a timer for a specific ticket in the Work page. " + - "IMPORTANT RULES: " + + 'Start a timer for a specific ticket in the Work page. ' + + 'IMPORTANT RULES: ' + "(1) This tool NEVER creates a ticket — if the ticket doesn't exist, tell the user and offer to use create_ticket. " + - "(2) The ticket must belong to the currently selected team — if not, use switch_team first. " + - "(3) The user must be clocked in to the selected team before a timer can start. " + + '(2) The ticket must belong to the currently selected team — if not, use switch_team first. ' + + '(3) The user must be clocked in to the selected team before a timer can start. ' + "(4) A work item row for today is automatically created if one doesn't exist. " + - "Recommended flow: get_teams → switch_team → get_clock_status → clock_in (if needed) → get_tickets → start_ticket_timer.", + 'Recommended flow: get_teams → switch_team → get_clock_status → clock_in (if needed) → get_tickets → start_ticket_timer.', parameters: { type: 'object', properties: { diff --git a/src/features/tickets/TicketsPage.tsx b/src/features/tickets/TicketsPage.tsx index 93951396..1f47941e 100644 --- a/src/features/tickets/TicketsPage.tsx +++ b/src/features/tickets/TicketsPage.tsx @@ -870,7 +870,9 @@ export const TicketsPage: React.FC = () => { label="Team" activeLabel={activeTeamLabel} open={openFilterMenu === 'team'} - onOpenChange={(open) => setOpenFilterMenu((prev) => (open ? 'team' : prev === 'team' ? null : prev))} + onOpenChange={(open) => + setOpenFilterMenu((prev) => (open ? 'team' : prev === 'team' ? null : prev)) + } > setTeamFilter(null)} @@ -894,7 +896,11 @@ export const TicketsPage: React.FC = () => { label="Priority" activeLabel={activePriorityLabel} open={openFilterMenu === 'priority'} - onOpenChange={(open) => setOpenFilterMenu((prev) => (open ? 'priority' : prev === 'priority' ? null : prev))} + onOpenChange={(open) => + setOpenFilterMenu((prev) => + open ? 'priority' : prev === 'priority' ? null : prev, + ) + } > setPriorityFilter(null)} @@ -918,7 +924,9 @@ export const TicketsPage: React.FC = () => { activeLabel={activeStatusDetailLabel} placement="bottom-end" open={openFilterMenu === 'status'} - onOpenChange={(open) => setOpenFilterMenu((prev) => (open ? 'status' : prev === 'status' ? null : prev))} + onOpenChange={(open) => + setOpenFilterMenu((prev) => (open ? 'status' : prev === 'status' ? null : prev)) + } > setStatusDetailFilter(null)} @@ -946,7 +954,11 @@ export const TicketsPage: React.FC = () => { activeLabel={activeAssigneeLabel} placement="bottom-end" open={openFilterMenu === 'assignee'} - onOpenChange={(open) => setOpenFilterMenu((prev) => (open ? 'assignee' : prev === 'assignee' ? null : prev))} + onOpenChange={(open) => + setOpenFilterMenu((prev) => + open ? 'assignee' : prev === 'assignee' ? null : prev, + ) + } > setAssigneeFilter(null)} diff --git a/src/features/timers/WorkPage.tsx b/src/features/timers/WorkPage.tsx index f47a0e5f..8e0dc3d5 100644 --- a/src/features/timers/WorkPage.tsx +++ b/src/features/timers/WorkPage.tsx @@ -232,7 +232,10 @@ export const WorkPage: React.FC = () => { }, [fetchDay]); useEffect(() => { - const handler = () => { void fetchDay(); void fetchWeekTotals(); }; + const handler = () => { + void fetchDay(); + void fetchWeekTotals(); + }; window.addEventListener('work:refetch', handler); return () => window.removeEventListener('work:refetch', handler); }, [fetchDay, fetchWeekTotals]); diff --git a/src/lib/api.ts b/src/lib/api.ts index b64117cf..62355223 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -99,7 +99,8 @@ async function request(path: string, options: RequestInit = {}): Pr const token = sessionToken.get(); const controller = new AbortController(); const timeoutId = setTimeout( - () => controller.abort(new Error('Request timed out. Please check your connection and try again.')), + () => + controller.abort(new Error('Request timed out. Please check your connection and try again.')), 8000, ); // overwrite the merged headers object (which would drop Authorization). @@ -138,7 +139,8 @@ async function request(path: string, options: RequestInit = {}): Pr async function timedFetch(url: string, options: RequestInit = {}): Promise { const controller = new AbortController(); const timeoutId = setTimeout( - () => controller.abort(new Error('Request timed out. Please check your connection and try again.')), + () => + controller.abort(new Error('Request timed out. Please check your connection and try again.')), 8000, ); try { From 6adfd01d7b1c4813fdc6e0707d0af0f4d24aaed2 Mon Sep 17 00:00:00 2001 From: Poonam Dharamkar Date: Tue, 26 May 2026 11:29:05 -0400 Subject: [PATCH 6/7] feat: enhance Jerry button styles and add eye tracking functionality in OzwellWidget --- src/features/ai/OzwellWidget.tsx | 139 ++++++++++++++++++++++++------- 1 file changed, 107 insertions(+), 32 deletions(-) diff --git a/src/features/ai/OzwellWidget.tsx b/src/features/ai/OzwellWidget.tsx index e7b0725e..c048e14e 100644 --- a/src/features/ai/OzwellWidget.tsx +++ b/src/features/ai/OzwellWidget.tsx @@ -75,7 +75,7 @@ function injectJerryButtonStyles() { border-radius: 14px !important; box-shadow: 0 4px 16px rgba(245, 166, 35, 0.4) !important; flex-direction: column !important; - gap: 2px !important; + gap: 3px !important; animation: jerry-bob 3.5s ease-in-out infinite !important; } #ozwell-chat-button:hover { @@ -88,50 +88,87 @@ function injectJerryButtonStyles() { 0%, 100% { transform: translateY(0px); } 50% { transform: translateY(-5px); } } + + /* ── Cute oval eyes ──────────────────────────────────── */ .jerry-eyes { display: flex; - gap: 8px; + gap: 6px; + align-items: center; } .jerry-eye { - width: 5px; - height: 5px; + position: relative; + width: 9px; + height: 10px; border-radius: 50%; - background: #3B2000; + background: white; + box-shadow: inset 0 1px 2px rgba(0,0,0,0.15); + overflow: hidden; animation: jerry-blink 5s ease-in-out infinite; transform-origin: center; } - .jerry-eye.r { animation-delay: 0.07s; } + .jerry-eye.r { animation-delay: 0.05s; } + /* Iris + pupil — position driven by --iris-x/y CSS vars set by mouse tracker */ + .jerry-eye::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + transform: translate( + calc(-50% + var(--iris-x, 0px)), + calc(-50% + var(--iris-y, 0px)) + ); + width: 8px; + height: 8px; + border-radius: 50%; + background: radial-gradient(circle at 35% 35%, #5a3a1a 0%, #1a0c00 65%); + transition: transform 0.06s linear; + } + /* Shine dot */ + .jerry-eye::after { + content: ''; + position: absolute; + top: 1px; + right: 1px; + width: 2px; + height: 2px; + border-radius: 50%; + background: rgba(255,255,255,0.95); + pointer-events: none; + z-index: 1; + } @keyframes jerry-blink { - 0%, 88%, 100% { transform: scaleY(1); } - 93% { transform: scaleY(0.08); } + 0%, 85%, 100% { transform: scaleY(1); } + 91% { transform: scaleY(0.07); } + } + /* Wink: left eye only squints on hover */ + #ozwell-chat-button:hover .jerry-eye.l { + animation: jerry-wink 0.35s ease-in-out forwards !important; + } + @keyframes jerry-wink { + 0% { transform: scaleY(1); } + 40% { transform: scaleY(0.07); } + 100% { transform: scaleY(1); } + } + + /* ── J — the hook is the smile ───────────────────────── */ + .jerry-face-bottom { + display: flex; + align-items: center; + justify-content: center; + margin-top: 2px; } .jerry-j { - font-size: 24px; - font-weight: 700; + font-size: 28px; + font-weight: 900; line-height: 1; color: #3B2000; font-family: Georgia, 'Times New Roman', serif; + transform: rotate(-5deg); + letter-spacing: -1px; + user-select: none; + text-shadow: 0 1px 2px rgba(0,0,0,0.15); } - .jerry-wave { - position: absolute; - top: -8px; - right: -10px; - font-size: 16px; - animation: jerry-wave 2.8s ease-in-out 0.5s both; - transform-origin: 70% 80%; - z-index: 1; - pointer-events: none; - } - @keyframes jerry-wave { - 0% { opacity: 0; transform: rotate(-20deg) scale(0.4); } - 12% { opacity: 1; transform: rotate(15deg) scale(1); } - 28% { transform: rotate(-10deg); } - 42% { transform: rotate(14deg); } - 56% { transform: rotate(-8deg); } - 72% { transform: rotate(6deg); } - 88% { transform: rotate(0deg); } - 100% { opacity: 1; transform: rotate(0deg); } - } + `; document.head.appendChild(style); } @@ -141,15 +178,52 @@ function injectJerryButtonContent() { const button = document.getElementById('ozwell-chat-button'); if (!button) return; button.innerHTML = ` -
👋
-
J
+
+ J +
`; } +/** Make the irises follow the mouse cursor around the page. */ +let eyeTrackingStarted = false; +function startEyeTracking() { + if (eyeTrackingStarted) return; + eyeTrackingStarted = true; + const MAX_OFFSET = 2; // px — clamped to sclera bounds + let rafPending = false; + let lastX = -1; + let lastY = -1; + + function update() { + rafPending = false; + const eyes = document.querySelectorAll('.jerry-eye'); + eyes.forEach((eye) => { + const rect = eye.getBoundingClientRect(); + const cx = rect.left + rect.width / 2; + const cy = rect.top + rect.height / 2; + const dx = lastX - cx; + const dy = lastY - cy; + const dist = Math.sqrt(dx * dx + dy * dy); + const ratio = dist > 0 ? Math.min(MAX_OFFSET / dist, 1) : 0; + eye.style.setProperty('--iris-x', `${(dx * ratio).toFixed(2)}px`); + eye.style.setProperty('--iris-y', `${(dy * ratio).toFixed(2)}px`); + }); + } + + document.addEventListener('mousemove', (e) => { + lastX = e.clientX; + lastY = e.clientY; + if (!rafPending) { + rafPending = true; + requestAnimationFrame(update); + } + }); +} + /** Hide tool-call-only turns that the Ozwell platform renders as "(no response)". */ function hideNoResponseBubbles(root: HTMLElement) { for (const el of Array.from(root.querySelectorAll('*'))) { @@ -327,6 +401,7 @@ export const OzwellWidget: React.FC = () => { const onReady = () => { injectJerryButtonStyles(); injectJerryButtonContent(); + startEyeTracking(); }; document.addEventListener('ozwell-chat-ready', onReady); // Widget may already be ready if this effect runs late From c1422d7a1c99026b7d35cd8b095bc1d7a777f656 Mon Sep 17 00:00:00 2001 From: Poonam Dharamkar Date: Tue, 26 May 2026 12:27:06 -0400 Subject: [PATCH 7/7] fix: adjust Jerry button styles and remove eye tracking functionality in OzwellWidget --- src/features/ai/OzwellWidget.tsx | 118 +++---------------------------- 1 file changed, 11 insertions(+), 107 deletions(-) diff --git a/src/features/ai/OzwellWidget.tsx b/src/features/ai/OzwellWidget.tsx index c048e14e..1466d94a 100644 --- a/src/features/ai/OzwellWidget.tsx +++ b/src/features/ai/OzwellWidget.tsx @@ -75,7 +75,7 @@ function injectJerryButtonStyles() { border-radius: 14px !important; box-shadow: 0 4px 16px rgba(245, 166, 35, 0.4) !important; flex-direction: column !important; - gap: 3px !important; + gap: 2px !important; animation: jerry-bob 3.5s ease-in-out infinite !important; } #ozwell-chat-button:hover { @@ -88,87 +88,30 @@ function injectJerryButtonStyles() { 0%, 100% { transform: translateY(0px); } 50% { transform: translateY(-5px); } } - - /* ── Cute oval eyes ──────────────────────────────────── */ .jerry-eyes { display: flex; - gap: 6px; - align-items: center; + gap: 8px; } .jerry-eye { - position: relative; - width: 9px; - height: 10px; + width: 5px; + height: 5px; border-radius: 50%; - background: white; - box-shadow: inset 0 1px 2px rgba(0,0,0,0.15); - overflow: hidden; + background: #3B2000; animation: jerry-blink 5s ease-in-out infinite; transform-origin: center; } - .jerry-eye.r { animation-delay: 0.05s; } - /* Iris + pupil — position driven by --iris-x/y CSS vars set by mouse tracker */ - .jerry-eye::before { - content: ''; - position: absolute; - top: 50%; - left: 50%; - transform: translate( - calc(-50% + var(--iris-x, 0px)), - calc(-50% + var(--iris-y, 0px)) - ); - width: 8px; - height: 8px; - border-radius: 50%; - background: radial-gradient(circle at 35% 35%, #5a3a1a 0%, #1a0c00 65%); - transition: transform 0.06s linear; - } - /* Shine dot */ - .jerry-eye::after { - content: ''; - position: absolute; - top: 1px; - right: 1px; - width: 2px; - height: 2px; - border-radius: 50%; - background: rgba(255,255,255,0.95); - pointer-events: none; - z-index: 1; - } + .jerry-eye.r { animation-delay: 0.07s; } @keyframes jerry-blink { - 0%, 85%, 100% { transform: scaleY(1); } - 91% { transform: scaleY(0.07); } - } - /* Wink: left eye only squints on hover */ - #ozwell-chat-button:hover .jerry-eye.l { - animation: jerry-wink 0.35s ease-in-out forwards !important; - } - @keyframes jerry-wink { - 0% { transform: scaleY(1); } - 40% { transform: scaleY(0.07); } - 100% { transform: scaleY(1); } - } - - /* ── J — the hook is the smile ───────────────────────── */ - .jerry-face-bottom { - display: flex; - align-items: center; - justify-content: center; - margin-top: 2px; + 0%, 88%, 100% { transform: scaleY(1); } + 93% { transform: scaleY(0.08); } } .jerry-j { - font-size: 28px; - font-weight: 900; + font-size: 24px; + font-weight: 700; line-height: 1; color: #3B2000; font-family: Georgia, 'Times New Roman', serif; - transform: rotate(-5deg); - letter-spacing: -1px; - user-select: none; - text-shadow: 0 1px 2px rgba(0,0,0,0.15); } - `; document.head.appendChild(style); } @@ -182,48 +125,10 @@ function injectJerryButtonContent() {
-
- J -
+
J
`; } -/** Make the irises follow the mouse cursor around the page. */ -let eyeTrackingStarted = false; -function startEyeTracking() { - if (eyeTrackingStarted) return; - eyeTrackingStarted = true; - const MAX_OFFSET = 2; // px — clamped to sclera bounds - let rafPending = false; - let lastX = -1; - let lastY = -1; - - function update() { - rafPending = false; - const eyes = document.querySelectorAll('.jerry-eye'); - eyes.forEach((eye) => { - const rect = eye.getBoundingClientRect(); - const cx = rect.left + rect.width / 2; - const cy = rect.top + rect.height / 2; - const dx = lastX - cx; - const dy = lastY - cy; - const dist = Math.sqrt(dx * dx + dy * dy); - const ratio = dist > 0 ? Math.min(MAX_OFFSET / dist, 1) : 0; - eye.style.setProperty('--iris-x', `${(dx * ratio).toFixed(2)}px`); - eye.style.setProperty('--iris-y', `${(dy * ratio).toFixed(2)}px`); - }); - } - - document.addEventListener('mousemove', (e) => { - lastX = e.clientX; - lastY = e.clientY; - if (!rafPending) { - rafPending = true; - requestAnimationFrame(update); - } - }); -} - /** Hide tool-call-only turns that the Ozwell platform renders as "(no response)". */ function hideNoResponseBubbles(root: HTMLElement) { for (const el of Array.from(root.querySelectorAll('*'))) { @@ -401,7 +306,6 @@ export const OzwellWidget: React.FC = () => { const onReady = () => { injectJerryButtonStyles(); injectJerryButtonContent(); - startEyeTracking(); }; document.addEventListener('ozwell-chat-ready', onReady); // Widget may already be ready if this effect runs late