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/jerry-agent.yaml b/jerry-agent.yaml new file mode 100644 index 00000000..e2f88efe --- /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 new file mode 100644 index 00000000..1466d94a --- /dev/null +++ b/src/features/ai/OzwellWidget.tsx @@ -0,0 +1,950 @@ +/** + * 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, 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 { + interface Window { + OzwellChatConfig?: { + apiKey: string; + debug?: boolean; + tools?: unknown[]; + }; + 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'; +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; + } + `; + 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
+ `; +} + +/** 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; + 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(); + const { pathname, navigate } = useRouter(); + 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. + const ctxRef = useRef({ + user, + pathname, + selectedTeam, + selectedTeamId, + teams, + activeClockEvent, + refetchClock, + navigate, + setSelectedTeamId, + }); + useEffect(() => { + ctxRef.current = { + 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(() => { + 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), + }; + + injectMobileOverride(); + injectJerryButtonStyles(); // inject before script so button is styled on creation + + 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(); + document.getElementById(MOBILE_OVERRIDE_STYLE_ID)?.remove(); + document.getElementById(JERRY_BUTTON_STYLE_ID)?.remove(); + delete window.OzwellChatConfig; + }; + }, []); // intentionally empty — run once + + // ── 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: 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({ + userId: user?.id ?? null, + userName: user?.name ?? null, + 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, activeClockEvent, teams]); + + // ── Effect 5: 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_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, + }, + }); + } else { + respond({ success: true, data: { clockedIn: false } }); + } + break; + } + + case 'clock_in': { + if (!ctx.selectedTeamId) { + 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); + 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; + } + 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; + } + + 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, + }); + } + window.dispatchEvent(new Event('tickets:refetch')); + 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); + 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) { + 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; + } + + // Work / Timers + case 'get_work_items': { + const date = String(args.date ?? new Date().toLocaleDateString('en-CA')); + const entries = await timerApi.getDay(date); + 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; + } + + 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); + window.dispatchEvent(new Event('work:refetch')); + 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); + window.dispatchEvent(new Event('work:refetch')); + respond({ success: true, data: stopped }); + break; + } + + case 'get_running_timer': { + const running = await timerApi.getRunning(); + respond({ success: true, data: running ?? null }); + break; + } + + /** + * 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': { + 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; + } + + // ── 1. Resolve ticketId (lookup only — never create) ────────────── + let ticketId = String(args.ticketId ?? ''); + const ticketTitle = String(args.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; + } + } 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 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); + window.dispatchEvent(new Event('work:refetch')); + respond({ success: true, data: { workItem: entry, session } }); + 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..cc1feedd --- /dev/null +++ b/src/features/ai/ozwell-tools.ts @@ -0,0 +1,442 @@ +/** + * 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 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: [] }, + }, + }, + { + 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. 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. 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'], + }, + }, + }, + { + 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: '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: { + 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'], + }, + }, + }, + + // ── 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/clock/ClockPage.tsx b/src/features/clock/ClockPage.tsx index c88c1f79..d4228db9 100644 --- a/src/features/clock/ClockPage.tsx +++ b/src/features/clock/ClockPage.tsx @@ -10,11 +10,13 @@ import { useTeam } from '../../lib/TeamContext'; import { formatTimer, getActiveClockSeconds } 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, @@ -138,13 +140,21 @@ export const ClockPage: React.FC = () => { Coming soon… In the meantime, track your time on the{' '} - + {' '} page or manage your{' '} - + . diff --git a/src/features/tickets/TicketsPage.tsx b/src/features/tickets/TicketsPage.tsx index d35283e9..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(open ? 'team' : null)} + 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(open ? 'priority' : null)} + 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(open ? 'status' : null)} + 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(open ? 'assignee' : null)} + 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 37223da4..8e0dc3d5 100644 --- a/src/features/timers/WorkPage.tsx +++ b/src/features/timers/WorkPage.tsx @@ -231,6 +231,15 @@ 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; diff --git a/src/lib/api.ts b/src/lib/api.ts index 37b2c7e1..62355223 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -98,8 +98,11 @@ 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 +138,11 @@ 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 { diff --git a/src/ui/AppLayout.tsx b/src/ui/AppLayout.tsx index ff171edc..01a6a74a 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 { OrganizationMembersPage } from '../features/org/OrganizationMembersPage'; import { OrganizationOverviewPage } from '../features/org/OrganizationOverviewPage'; @@ -144,6 +145,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; @@ -278,6 +285,7 @@ export const AppLayout: React.FC = () => {
{profileUserId ? ( @@ -295,6 +303,7 @@ export const AppLayout: React.FC = () => { {(!isMessagesPage || !messagesHasActiveChat) && }
+