diff --git a/.eslintrc.cjs b/.eslintrc.cjs deleted file mode 100644 index 8e608153e..000000000 --- a/.eslintrc.cjs +++ /dev/null @@ -1,13 +0,0 @@ -/** @type {import("eslint").Linter.Config} */ -module.exports = { - root: true, - extends: ["eslint-config-custom"], - parserOptions: { - tsconfigRootDir: __dirname, - project: [ - "./tsconfig.json", - "./apps/*/tsconfig.json", - "./packages/*/tsconfig.json", - ], - }, -}; diff --git a/.github/actions/ref_from_sha/action.yml b/.github/actions/ref_from_sha/action.yml index a589b58af..06e9edcc4 100644 --- a/.github/actions/ref_from_sha/action.yml +++ b/.github/actions/ref_from_sha/action.yml @@ -13,4 +13,4 @@ outputs: description: "The number of the PR if one is found" runs: using: "node20" - main: "index.js" + main: "index.cjs" diff --git a/.github/actions/ref_from_sha/branch_from_sha.js b/.github/actions/ref_from_sha/branch_from_sha.cjs similarity index 100% rename from .github/actions/ref_from_sha/branch_from_sha.js rename to .github/actions/ref_from_sha/branch_from_sha.cjs diff --git a/.github/actions/ref_from_sha/index.js b/.github/actions/ref_from_sha/index.cjs similarity index 95% rename from .github/actions/ref_from_sha/index.js rename to .github/actions/ref_from_sha/index.cjs index 9356f47e3..393ca3ef3 100644 --- a/.github/actions/ref_from_sha/index.js +++ b/.github/actions/ref_from_sha/index.cjs @@ -8,8 +8,8 @@ const core = require("@actions/core"); const github = require("@actions/github"); -const prFromSha = require("./pr_from_sha"); -const branchFromSha = require("./branch_from_sha"); +const prFromSha = require("./pr_from_sha.cjs"); +const branchFromSha = require("./branch_from_sha.cjs"); async function run() { try { diff --git a/.github/actions/ref_from_sha/pr_from_sha.js b/.github/actions/ref_from_sha/pr_from_sha.cjs similarity index 100% rename from .github/actions/ref_from_sha/pr_from_sha.js rename to .github/actions/ref_from_sha/pr_from_sha.cjs diff --git a/.github/actions/ref_from_sha/test/e2e.js b/.github/actions/ref_from_sha/test/e2e.cjs similarity index 90% rename from .github/actions/ref_from_sha/test/e2e.js rename to .github/actions/ref_from_sha/test/e2e.cjs index 66d036752..475e3933a 100644 --- a/.github/actions/ref_from_sha/test/e2e.js +++ b/.github/actions/ref_from_sha/test/e2e.cjs @@ -1,7 +1,7 @@ const github = require("@actions/github"); -const prFromSha = require("../pr_from_sha"); -const branchFromSha = require("../branch_from_sha"); +const prFromSha = require("../pr_from_sha.cjs"); +const branchFromSha = require("../branch_from_sha.cjs"); const githubToken = process.env.GITHUB_TOKEN; diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index cad1aa60e..149fec4e0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -43,4 +43,15 @@ jobs: inject-env-vars: true - name: Run tests - run: pnpm turbo test --cache-dir=".turbo" -- --maxWorkers=33% + run: pnpm turbo test --cache-dir=".turbo" -- --maxWorkers=33% --coverage + + # Run only on production branch + - name: Report coverage to SonarCloud + if: ${{ github.event.pull_request.merged == true && github.base_ref == 'production' }} + uses: sonarsource/sonarcloud-github-action@master + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + with: + args: > + -Dsonar.javascript.lcov.reportPaths=packages/**/coverage/lcov.info,apps/nextjs/coverage/lcov.info diff --git a/apps/nextjs/.eslintrc.cjs b/apps/nextjs/.eslintrc.cjs deleted file mode 100644 index 9b9cbd01b..000000000 --- a/apps/nextjs/.eslintrc.cjs +++ /dev/null @@ -1,24 +0,0 @@ -/** @type {import("eslint").Linter.Config} */ -module.exports = { - extends: ["../../.eslintrc.cjs", "next", "plugin:storybook/recommended"], - rules: { - "react/prefer-read-only-props": "error", - "react/jsx-no-useless-fragment": "warn", - "no-restricted-imports": [ - "error", - { - paths: [ - { - name: "posthog-js/react", - importNames: ["usePostHog"], - message: - "usePostHog doesn't support multiple PostHog instances, use useAnalytics instead", - }, - ], - }, - ], - }, - parserOptions: { - project: __dirname + "/tsconfig.json", - }, -}; diff --git a/apps/nextjs/jest.config.js b/apps/nextjs/jest.config.mjs similarity index 66% rename from apps/nextjs/jest.config.js rename to apps/nextjs/jest.config.mjs index d35dc0cb3..7898f8abb 100644 --- a/apps/nextjs/jest.config.js +++ b/apps/nextjs/jest.config.mjs @@ -1,5 +1,9 @@ -const { pathsToModuleNameMapper } = require("ts-jest"); -const { compilerOptions } = require("./tsconfig.test.json"); +import { readFile } from "fs/promises"; +import { pathsToModuleNameMapper } from "ts-jest"; + +const tsconfig = JSON.parse( + await readFile(new URL("./tsconfig.test.json", import.meta.url)), +); /** @type {import('ts-jest').JestConfigWithTsJest} */ const config = { @@ -9,14 +13,15 @@ const config = { { tsconfig: "tsconfig.test.json", useESM: true, + isolatedModules: true, }, ], - "^.+\\.svg$": "/jest.svgTransform.js", + "^.+\\.svg$": "/jest.svgTransform.mjs", "^.+\\.(css|scss|png|jpg|jpeg|gif|webp|avif)$": "jest-transform-stub", }, preset: "ts-jest/presets/default-esm", moduleNameMapper: { - ...pathsToModuleNameMapper(compilerOptions.paths, { + ...pathsToModuleNameMapper(tsconfig.compilerOptions.paths, { prefix: "/src/", }), "^(\\.{1,2}/.*)\\.js$": "$1", @@ -29,11 +34,12 @@ const config = { moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"], rootDir: ".", resetMocks: true, - setupFilesAfterEnv: ["/jest.setup.js"], + setupFilesAfterEnv: ["/jest.setup.cjs"], + collectCoverageFrom: ["src/**/*.{ts,tsx,js,jsx}"], collectCoverage: process.env.CI === "true" || process.env.COLLECT_TEST_COVERAGE === "true", coverageReporters: ["lcov", "text"], coverageDirectory: "coverage", }; -module.exports = config; +export default config; diff --git a/apps/nextjs/jest.setup.js b/apps/nextjs/jest.setup.cjs similarity index 100% rename from apps/nextjs/jest.setup.js rename to apps/nextjs/jest.setup.cjs diff --git a/apps/nextjs/jest.static.d.ts b/apps/nextjs/jest.static.d.ts index 95daa5e7f..ded555fbb 100644 --- a/apps/nextjs/jest.static.d.ts +++ b/apps/nextjs/jest.static.d.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ declare module "*.svg" { const content: any; export default content; diff --git a/apps/nextjs/jest.svgTransform.js b/apps/nextjs/jest.svgTransform.mjs similarity index 89% rename from apps/nextjs/jest.svgTransform.js rename to apps/nextjs/jest.svgTransform.mjs index 16506e1c5..685e8670c 100644 --- a/apps/nextjs/jest.svgTransform.js +++ b/apps/nextjs/jest.svgTransform.mjs @@ -1,4 +1,4 @@ -module.exports = { +export default { process() { console.log("Called jest svg transform"); return { code: "module.exports = {};" }; diff --git a/apps/nextjs/next-env.d.ts b/apps/nextjs/next-env.d.ts index 4f11a03dc..40c3d6809 100644 --- a/apps/nextjs/next-env.d.ts +++ b/apps/nextjs/next-env.d.ts @@ -2,4 +2,4 @@ /// // NOTE: This file should not be edited -// see https://nextjs.org/docs/basic-features/typescript for more information. +// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information. diff --git a/apps/nextjs/next.config.js b/apps/nextjs/next.config.js index 6f9dad15f..0dd375c38 100644 --- a/apps/nextjs/next.config.js +++ b/apps/nextjs/next.config.js @@ -1,9 +1,10 @@ +// This file should be in Common JS format to be compatible with Next.js const { getAppVersion, getReleaseStage, RELEASE_STAGE_PRODUCTION, RELEASE_STAGE_TESTING, -} = require("./scripts/build_config_helpers.js"); +} = require("./scripts/build_config_helpers.cjs"); const path = require("path"); const { PHASE_PRODUCTION_BUILD, PHASE_TEST } = require("next/constants"); diff --git a/apps/nextjs/package.json b/apps/nextjs/package.json index f78bce278..3547f6d63 100644 --- a/apps/nextjs/package.json +++ b/apps/nextjs/package.json @@ -1,26 +1,27 @@ { "name": "@oakai/nextjs", - "version": "0.1.0", + "version": "1.0.0", "private": true, "scripts": { "build": "next build", "build:dev": "pnpm with-env next build", "check": "tsc --noEmit", "clean": "rm -rf .next .turbo node_modules", - "dev": "FORCE_COLOR=1 SENTRY_SUPPRESS_TURBOPACK_WARNING=1 pnpm with-env node scripts/increase-listeners.js next dev --port 2525 --turbo | pino-pretty -C", - "dev:sentry": "FORCE_COLOR=1 pnpm with-env node scripts/increase-listeners.js next dev --port 2525 | pino-pretty -C", + "dev": "FORCE_COLOR=1 SENTRY_SUPPRESS_TURBOPACK_WARNING=1 pnpm with-env next dev --port 2525 --turbo | pino-pretty -C", + "dev:sentry": "FORCE_COLOR=1 pnpm with-env next dev --port 2525 | pino-pretty -C", "dev-trace-deprecation": "NODE_OPTIONS=\"--trace-deprecation\" next dev --port 2525 | pino-pretty -C", - "lint": "next lint", + "lint": "eslint .", "lint-fix": "next lint --fix", + "lint-debug": "eslint --debug .", "start": "next start", "type-check": "tsc --noEmit", - "test": "pnpm with-env jest --colors --config jest.config.js", - "test:seq": "pnpm with-env jest --colors --config jest.config.js --verbose --runInBand --no-cache", + "test": "pnpm with-env jest --colors --config jest.config.mjs", + "test:seq": "pnpm with-env jest --colors --config jest.config.mjs --verbose --runInBand --no-cache", "test-e2e": "pnpm with-env playwright test", "test-e2e-ui": "pnpm with-env playwright test --ui", "test-e2e-ui-serve": "pnpm build && pnpm start --port 4848 --turbo", "test-e2e-ui-built": "PORT=4848 pnpm with-env playwright test --ui", - "test-coverage": "COLLECT_TEST_COVERAGE=true pnpm with-env jest --colors --config jest.config.js --coverage", + "test-coverage": "COLLECT_TEST_COVERAGE=true pnpm with-env jest --colors --config jest.config.mjs --coverage", "with-env": "dotenv -e ../../.env --", "aila": "tsx scripts/aila-cli.ts", "storybook": "dotenv -e ../../.env -- storybook dev -p 6006 --no-open", @@ -43,14 +44,15 @@ "@oakai/api": "*", "@oakai/core": "*", "@oakai/db": "*", + "@oakai/eslint-config": "*", "@oakai/exports": "*", "@oakai/logger": "*", "@oakai/prettier-config": "*", "@oaknational/oak-components": "^1.50.0", "@oaknational/oak-consent-client": "^2.1.0", "@portabletext/react": "^3.1.0", - "@prisma/client": "5.16.1", - "@prisma/extension-accelerate": "^1.0.0", + "@prisma/client": "^5.16.1", + "@prisma/extension-accelerate": "^1.2.1", "@radix-ui/react-accordion": "^1.1.2", "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-tooltip": "^1.0.7", @@ -141,8 +143,8 @@ "@storybook/test": "^8.4.1", "@tailwindcss/typography": "^0.5.10", "@types/file-saver": "^2.0.6", - "@types/jest": "^29.5.12", - "@types/node": "^18.17.0", + "@types/jest": "^29.5.14", + "@types/node": "^20.9.0", "@types/react": "^18.2.37", "@types/react-dom": "^18.2.15", "@types/styled-components": "^5.1.34", @@ -150,17 +152,14 @@ "avo": "^3.2.11", "concurrently": "^8.2.2", "dotenv-cli": "^6.0.0", - "eslint": "^8.56.0", - "eslint-config-next": "15.0.1", - "eslint-plugin-storybook": "^0.8.0", "graphql": "^16.9.0", "jest": "^29.7.0", "msw": "^2.6.5", "msw-storybook-addon": "^2.0.4", "postcss": "^8.4.32", "tailwindcss": "^3.3.7", - "ts-jest": "^29.1.4", - "typescript": "5.3.3", + "ts-jest": "^29.2.5", + "typescript": "5.7.2", "web-streams-polyfill": "^4.0.0" }, "engines": { @@ -171,5 +170,12 @@ "workerDirectory": [ ".storybook/public" ] + }, + "eslintConfig": { + "extends": "@oakai/eslint-config", + "parserOptions": { + "project": "./tsconfig.json" + }, + "rules": {} } } diff --git a/apps/nextjs/scripts/build_config_helpers.js b/apps/nextjs/scripts/build_config_helpers.cjs similarity index 99% rename from apps/nextjs/scripts/build_config_helpers.js rename to apps/nextjs/scripts/build_config_helpers.cjs index 4016a0c86..ca4a2b30f 100644 --- a/apps/nextjs/scripts/build_config_helpers.js +++ b/apps/nextjs/scripts/build_config_helpers.cjs @@ -7,7 +7,7 @@ const { existsSync, readFileSync } = require("fs"); * @returns {(string|null)} The SHA if found, or "no_git_state" if mid-merge, or `null` if it cannot be determined. */ function getLocalGitRef() { - if (existsSync("../../.git")) { + if (existsSync("../../.git/HEAD")) { const rev = readFileSync("../../.git/HEAD") .toString() .trim() diff --git a/apps/nextjs/scripts/increase-listeners.js b/apps/nextjs/scripts/increase-listeners.js deleted file mode 100644 index 7d9e0c25a..000000000 --- a/apps/nextjs/scripts/increase-listeners.js +++ /dev/null @@ -1,18 +0,0 @@ -/**** - Because we have a reasonably complex Next.js project now, - we're sometimes running into the default max listeners limit. - This script increases the limit to 20, which should be enough - so that we don't run into this issue. - - Potentially, if we decide to move to Turbopack for compilation - in local development, we could remove this script. - -***/ - -// Increase the limit of max listeners -require("events").EventEmitter.defaultMaxListeners = 20; - -// Run the original command -require("child_process").spawn(process.argv[2], process.argv.slice(3), { - stdio: "inherit", -}); diff --git a/apps/nextjs/scripts/preload-chat-routes.mjs b/apps/nextjs/scripts/preload-chat-routes.mjs index 382413a45..de599b985 100644 --- a/apps/nextjs/scripts/preload-chat-routes.mjs +++ b/apps/nextjs/scripts/preload-chat-routes.mjs @@ -75,6 +75,7 @@ const preBuildRoutes = async ( console.log("All routes pre-built successfully"); console.timeEnd(timerId); } catch (error) { + console.error(error); if (retryCount < maxRetries) { console.log( `Retrying pre-build (attempt ${retryCount + 1} of ${maxRetries})...`, diff --git a/apps/nextjs/src/ai-apps/lesson-planner/state/actions.ts b/apps/nextjs/src/ai-apps/lesson-planner/state/actions.ts index 16ef00a41..3b68c7c5f 100644 --- a/apps/nextjs/src/ai-apps/lesson-planner/state/actions.ts +++ b/apps/nextjs/src/ai-apps/lesson-planner/state/actions.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-duplicate-enum-values */ import type { RateLimitInfo } from "@oakai/api/src/types"; import type { KeyStageName, SubjectName } from "@oakai/core"; import type { diff --git a/apps/nextjs/src/app/api/chat/fixtures/FixtureRecordOpenAiClient.ts b/apps/nextjs/src/app/api/chat/fixtures/FixtureRecordOpenAiClient.ts index cb0d5d686..68d340390 100644 --- a/apps/nextjs/src/app/api/chat/fixtures/FixtureRecordOpenAiClient.ts +++ b/apps/nextjs/src/app/api/chat/fixtures/FixtureRecordOpenAiClient.ts @@ -3,7 +3,7 @@ import { createOpenAIClient } from "@oakai/core/src/llm/openai"; import { aiLogger } from "@oakai/logger"; import fs from "fs/promises"; import type OpenAI from "openai"; -import type { ChatCompletionCreateParamsNonStreaming } from "openai/resources"; +import type { ChatCompletionCreateParamsNonStreaming } from "openai/resources/index.mjs"; const log = aiLogger("fixtures"); diff --git a/apps/nextjs/src/app/api/cron-jobs/expired-exports/route.ts b/apps/nextjs/src/app/api/cron-jobs/expired-exports/route.ts index 43a68a2a6..c2b0ca4d5 100644 --- a/apps/nextjs/src/app/api/cron-jobs/expired-exports/route.ts +++ b/apps/nextjs/src/app/api/cron-jobs/expired-exports/route.ts @@ -1,3 +1,4 @@ +import type { drive_v3 } from "@googleapis/drive"; import { prisma } from "@oakai/db"; import { googleDrive } from "@oakai/exports/src/gSuite/drive/client"; import { aiLogger } from "@oakai/logger"; @@ -126,19 +127,35 @@ export async function GET(request: NextRequest) { return new Response("Unauthorized", { status: 401 }); } - const files = await fetchExpiredExports({ folderId, daysAgo: 14 }); + let files: drive_v3.Schema$File[] | null; + let hasMoreFiles = true; - if (!files || files.length === 0) { - return new Response("No expired files found", { status: 404 }); - } + while (hasMoreFiles) { + files = await fetchExpiredExports({ folderId, daysAgo: 14 }); + + if (!files || files.length === 0) { + log.info("No expired files found."); + hasMoreFiles = false; + break; + } - const validFileIds = files.map((file) => file.id).filter(isTruthy); + const validFileIds = files.map((file) => file.id).filter(isTruthy); + + if (validFileIds.length === 0) { + log.info("No valid file IDs to process."); + hasMoreFiles = false; + break; + } - await updateExpiredAtAndDelete(validFileIds); + await updateExpiredAtAndDelete(validFileIds); + } + + return new Response("All expired files processed successfully.", { + status: 200, + }); } catch (error) { Sentry.captureException(error); + log.error("An error occurred during the cron job execution:", error); return new Response("Internal Server Error", { status: 500 }); } - - return new Response(JSON.stringify({ success: true }), { status: 200 }); } diff --git a/apps/nextjs/src/app/api/cron-jobs/google-drive-size-quota/route.ts b/apps/nextjs/src/app/api/cron-jobs/google-drive-size-quota/route.ts new file mode 100644 index 000000000..8590fba0a --- /dev/null +++ b/apps/nextjs/src/app/api/cron-jobs/google-drive-size-quota/route.ts @@ -0,0 +1,114 @@ +import { + slackAiOpsNotificationChannelId, + slackWebClient, +} from "@oakai/core/src/utils/slack"; +import { googleDrive } from "@oakai/exports/src/gSuite/drive/client"; +import { aiLogger } from "@oakai/logger"; +import * as Sentry from "@sentry/node"; +import type { NextRequest } from "next/server"; + +const log = aiLogger("cron"); + +const requiredEnvVars = ["CRON_SECRET", "SLACK_AI_OPS_NOTIFICATION_CHANNEL_ID"]; + +requiredEnvVars.forEach((envVar) => { + if (!process.env[envVar]) { + throw new Error(`Environment variable ${envVar} is not set.`); + } +}); + +async function fetchDriveUsage() { + try { + const res = await googleDrive.about.get({ + fields: "storageQuota, user(emailAddress)", + }); + + const storageQuota = res.data.storageQuota; + const userEmail = res.data.user?.emailAddress; + + if (!storageQuota) { + throw new Error("Unable to fetch storage quota information."); + } + + const usage = { + limit: parseInt(storageQuota.limit ?? "0", 10), + usage: parseInt(storageQuota.usage ?? "0", 10), + userEmail, + }; + + log.info( + `Drive usage retrieved: ${usage.usage} bytes used of ${usage.limit} bytes total, ${userEmail}.`, + ); + + return usage; + } catch (error) { + log.error("Failed to fetch Google Drive usage details:", error); + throw error; + } +} + +async function checkDriveUsageThreshold(thresholdPercentage: number = 80) { + try { + const usage = await fetchDriveUsage(); + + if (usage.limit === 0) { + throw new Error("Storage limit is reported as zero, which is invalid."); + } + + const usagePercentage = (usage.usage / usage.limit) * 100; + + log.info( + `Drive usage percentage: ${usagePercentage.toFixed( + 2, + )}%. Threshold is set at ${thresholdPercentage}%.`, + ); + + if (usagePercentage > thresholdPercentage) { + const errorMessage = `Drive usage is at ${usagePercentage.toFixed( + 2, + )}% of the total limit, exceeding the threshold of ${thresholdPercentage}% : ${usage.userEmail}`; + log.error(errorMessage); + Sentry.captureMessage(errorMessage); + await slackWebClient.chat.postMessage({ + channel: slackAiOpsNotificationChannelId, + text: errorMessage, + }); + } + } catch (error) { + log.error("Error during Drive usage check:", error); + Sentry.captureException(error); + throw error; + } +} + +export async function GET(request: NextRequest) { + try { + const authHeader = request.headers.get("authorization"); + const cronSecret = process.env.CRON_SECRET; + + if (!cronSecret) { + log.error("Missing cron secret"); + return new Response("Missing cron secret", { status: 500 }); + } + + if (authHeader !== `Bearer ${cronSecret}`) { + log.error("Authorization failed. Invalid token."); + return new Response("Unauthorized", { status: 401 }); + } + + log.info("Starting Google Drive usage check..."); + + await checkDriveUsageThreshold(80); + + return new Response("Drive usage check completed successfully.", { + status: 200, + }); + } catch (error) { + log.error( + "An error occurred during the Drive usage check cron job:", + error, + ); + Sentry.captureException(error); + return new Response("Internal Server Error", { status: 500 }); + } +} diff --git a/apps/nextjs/src/components/AppComponents/Chat/button-scroll-to-bottom.tsx b/apps/nextjs/src/components/AppComponents/Chat/button-scroll-to-bottom.tsx deleted file mode 100644 index c5fe3b3bc..000000000 --- a/apps/nextjs/src/components/AppComponents/Chat/button-scroll-to-bottom.tsx +++ /dev/null @@ -1,40 +0,0 @@ -"use client"; - -import { - Button, - type ButtonProps, -} from "@/components/AppComponents/Chat/ui/button"; -import { IconArrowDown } from "@/components/AppComponents/Chat/ui/icons"; -import useAnalytics from "@/lib/analytics/useAnalytics"; -import { useAtBottom } from "@/lib/hooks/use-at-bottom"; -import { cn } from "@/lib/utils"; - -export function ButtonScrollToBottom({ - className, - ...props -}: Readonly) { - const isAtBottom = useAtBottom(); - const { trackEvent } = useAnalytics(); - return ( - - ); -} diff --git a/apps/nextjs/src/components/AppComponents/Chat/chat-left-hand-side.tsx b/apps/nextjs/src/components/AppComponents/Chat/chat-left-hand-side.tsx index ba3fab32e..d2ff39208 100644 --- a/apps/nextjs/src/components/AppComponents/Chat/chat-left-hand-side.tsx +++ b/apps/nextjs/src/components/AppComponents/Chat/chat-left-hand-side.tsx @@ -39,6 +39,7 @@ const ChatLeftHandSide = ({ setShowLessonMobile={setShowLessonMobile} showLessonMobile={showLessonMobile} isDemoUser={isDemoUser} + showStreamingStatus={process.env.NEXT_PUBLIC_ENVIRONMENT !== "prd"} />
@@ -48,14 +49,9 @@ const ChatLeftHandSide = ({ demo={demo} /> - {!isDemoLocked && ( - - )} + {!isDemoLocked && }
- + ); diff --git a/apps/nextjs/src/components/AppComponents/Chat/chat-lhs-header.stories.tsx b/apps/nextjs/src/components/AppComponents/Chat/chat-lhs-header.stories.tsx new file mode 100644 index 000000000..8f2e48dbd --- /dev/null +++ b/apps/nextjs/src/components/AppComponents/Chat/chat-lhs-header.stories.tsx @@ -0,0 +1,55 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import { + ChatContext, + type ChatContextProps, +} from "@/components/ContextProviders/ChatProvider"; + +import ChatLhsHeader from "./chat-lhs-header"; + +const ChatDecorator: Story["decorators"] = (Story, { parameters }) => ( + + + +); + +const meta: Meta = { + title: "Components/Chat/ChatLhsHeader", + component: ChatLhsHeader, + tags: ["autodocs"], + decorators: [ChatDecorator], + args: { + showStreamingStatus: false, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: {}, +}; + +export const NonProdStreamingStatus: Story = { + args: { + showStreamingStatus: true, + }, + parameters: { + chatContext: { + ailaStreamingStatus: "StreamingLessonPlan", + }, + }, +}; + +export const DemoBannerPadding: Story = { + args: { + isDemoUser: true, + }, +}; diff --git a/apps/nextjs/src/components/AppComponents/Chat/chat-lhs-header.tsx b/apps/nextjs/src/components/AppComponents/Chat/chat-lhs-header.tsx index 45fadb88e..06176163f 100644 --- a/apps/nextjs/src/components/AppComponents/Chat/chat-lhs-header.tsx +++ b/apps/nextjs/src/components/AppComponents/Chat/chat-lhs-header.tsx @@ -11,19 +11,21 @@ type ChatLhsHeaderProps = { setShowLessonMobile: (value: boolean) => void; showLessonMobile: boolean; isDemoUser: boolean; + showStreamingStatus: boolean; }; const ChatLhsHeader = ({ setShowLessonMobile, showLessonMobile, isDemoUser, + showStreamingStatus, }: Readonly) => { const router = useRouter(); const chat = useLessonChat(); return ( <>
- {process.env.NEXT_PUBLIC_ENVIRONMENT !== "prd" && ( + {showStreamingStatus && (
{chat.ailaStreamingStatus} diff --git a/apps/nextjs/src/components/AppComponents/Chat/chat-message/index.tsx b/apps/nextjs/src/components/AppComponents/Chat/chat-message/index.tsx index 06359a59c..4685bc77e 100644 --- a/apps/nextjs/src/components/AppComponents/Chat/chat-message/index.tsx +++ b/apps/nextjs/src/components/AppComponents/Chat/chat-message/index.tsx @@ -1,6 +1,6 @@ // Inspired by Chatbot-UI and modified to fit the needs of this project // @see https://github.com/mckaywrigley/chatbot-ui/blob/main/components/Chat/ChatMessage.tsx -import type { ReactNode } from "react"; +import type { ReactNode, JSX } from "react"; import { useState } from "react"; import type { MessagePart } from "@oakai/aila/src/protocol/jsonPatchProtocol"; @@ -200,7 +200,7 @@ function MessageWrapper({ function MessageTextWrapper({ children }: Readonly<{ children: ReactNode }>) { return ( -
+
{children}
); diff --git a/apps/nextjs/src/components/AppComponents/Chat/chat-panel.stories.tsx b/apps/nextjs/src/components/AppComponents/Chat/chat-panel.stories.tsx new file mode 100644 index 000000000..c2cff638c --- /dev/null +++ b/apps/nextjs/src/components/AppComponents/Chat/chat-panel.stories.tsx @@ -0,0 +1,178 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import { + ChatContext, + type ChatContextProps, +} from "@/components/ContextProviders/ChatProvider"; +import { lessonPlanTrackingContext } from "@/lib/analytics/lessonPlanTrackingContext"; +import { SidebarContext } from "@/lib/hooks/use-sidebar"; + +import { ChatPanel } from "./chat-panel"; + +const DummyMessage = {}; + +const ChatDecorator: Story["decorators"] = (Story, { parameters }) => ( + + + +); + +const LessonPlanTrackingContextDecorator: Story["decorators"] = (Story) => ( + {}, + onClickRetry: () => {}, + onClickStartFromExample: () => {}, + onClickStartFromFreeText: () => {}, + onStreamFinished: () => {}, + onSubmitText: () => {}, + }} + > + + +); + +const SidebarContextDecorator: Story["decorators"] = (Story) => ( + {}, + isLoading: false, + isSidebarOpen: false, + }} + > + + +); + +const meta: Meta = { + title: "Components/Chat/ChatPanel", + component: ChatPanel, + tags: ["autodocs"], + decorators: [ + ChatDecorator, + LessonPlanTrackingContextDecorator, + SidebarContextDecorator, + ], + args: { + isDemoLocked: false, + }, +}; + +export default meta; +type Story = StoryObj; + +export const NoMessages: Story = { + args: {}, + parameters: { + chatContext: { + messages: [], + }, + }, +}; + +export const DemoLocked: Story = { + args: { + isDemoLocked: true, + }, +}; + +export const Idle: Story = { + args: {}, + parameters: { + chatContext: { + ailaStreamingStatus: "Idle", + }, + }, +}; + +export const IdleWithQueuedUserAction: Story = { + args: {}, + parameters: { + chatContext: { + queuedUserAction: "regenerate", + ailaStreamingStatus: "Idle", + }, + }, +}; + +export const Loading: Story = { + args: {}, + parameters: { + chatContext: { + ailaStreamingStatus: "Loading", + }, + }, +}; + +export const RequestMade: Story = { + args: {}, + parameters: { + chatContext: { + ailaStreamingStatus: "RequestMade", + }, + }, +}; + +export const StreamingLessonPlan: Story = { + args: {}, + parameters: { + chatContext: { + ailaStreamingStatus: "StreamingLessonPlan", + }, + }, +}; + +export const StreamingChatResponse: Story = { + args: {}, + parameters: { + chatContext: { + ailaStreamingStatus: "StreamingChatResponse", + }, + }, +}; + +export const StreamingWithQueuedUserAction: Story = { + args: {}, + parameters: { + chatContext: { + queuedUserAction: "regenerate", + ailaStreamingStatus: "StreamingLessonPlan", + }, + }, +}; + +export const Moderating: Story = { + args: {}, + parameters: { + chatContext: { + ailaStreamingStatus: "Moderating", + }, + }, +}; + +export const ModeratingWithRegenerateUserAction: Story = { + args: {}, + parameters: { + chatContext: { + queuedUserAction: "regenerate", + ailaStreamingStatus: "Moderating", + }, + }, +}; + +export const CustomQueuedUserAction: Story = { + args: {}, + parameters: { + chatContext: { + queuedUserAction: "Increase the reading age of that section", + ailaStreamingStatus: "Moderating", + }, + }, +}; diff --git a/apps/nextjs/src/components/AppComponents/Chat/chat-panel.tsx b/apps/nextjs/src/components/AppComponents/Chat/chat-panel.tsx index fca32ed91..b2b58dcb2 100644 --- a/apps/nextjs/src/components/AppComponents/Chat/chat-panel.tsx +++ b/apps/nextjs/src/components/AppComponents/Chat/chat-panel.tsx @@ -1,6 +1,5 @@ import { cva } from "class-variance-authority"; -import { ButtonScrollToBottom } from "@/components/AppComponents/Chat/button-scroll-to-bottom"; import { PromptForm } from "@/components/AppComponents/Chat/prompt-form"; import { useLessonChat } from "@/components/ContextProviders/ChatProvider"; import useAnalytics from "@/lib/analytics/useAnalytics"; @@ -8,7 +7,6 @@ import useAnalytics from "@/lib/analytics/useAnalytics"; import ChatPanelDisclaimer from "./chat-panel-disclaimer"; interface ChatPanelProps { - isEmptyScreen: boolean; isDemoLocked: boolean; } @@ -18,13 +16,11 @@ function LockedPromptForm() { ); } -export function ChatPanel({ - isEmptyScreen, - isDemoLocked, -}: Readonly) { +export function ChatPanel({ isDemoLocked }: Readonly) { const chat = useLessonChat(); const { id, + messages, isLoading, input, setInput, @@ -34,12 +30,13 @@ export function ChatPanel({ queuedUserAction, } = chat; + const hasMessages = !!messages.length; + const { trackEvent } = useAnalytics(); - const containerClass = `grid w-full grid-cols-1 ${isEmptyScreen ? "sm:grid-cols-1" : ""} peer-[[data-state=open]]:group-[]:lg:pl-[250px] peer-[[data-state=open]]:group-[]:xl:pl-[300px]`; + const containerClass = `grid w-full grid-cols-1 ${hasMessages ? "sm:grid-cols-1" : ""} peer-[[data-state=open]]:group-[]:lg:pl-[250px] peer-[[data-state=open]]:group-[]:xl:pl-[300px]`; return (
- -
+
{!isDemoLocked && ( { @@ -57,7 +54,7 @@ export function ChatPanel({ input={input} setInput={setInput} ailaStreamingStatus={ailaStreamingStatus} - isEmptyScreen={isEmptyScreen} + hasMessages={hasMessages} queueUserAction={queueUserAction} queuedUserAction={queuedUserAction} /> @@ -73,7 +70,7 @@ export function ChatPanel({ const chatBoxWrap = cva(["mx-auto w-full "], { variants: { - isEmptyScreen: { + hasMessages: { false: "max-w-2xl ", true: "", }, diff --git a/apps/nextjs/src/components/AppComponents/Chat/chat-quick-buttons.stories.tsx b/apps/nextjs/src/components/AppComponents/Chat/chat-quick-buttons.stories.tsx new file mode 100644 index 000000000..a3baefe15 --- /dev/null +++ b/apps/nextjs/src/components/AppComponents/Chat/chat-quick-buttons.stories.tsx @@ -0,0 +1,133 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import { + ChatContext, + type ChatContextProps, +} from "@/components/ContextProviders/ChatProvider"; +import { lessonPlanTrackingContext } from "@/lib/analytics/lessonPlanTrackingContext"; + +import ChatQuickButtons from "./chat-quick-buttons"; + +const DummyMessage = {}; + +const ChatDecorator: Story["decorators"] = (Story, { parameters }) => ( + + + +); + +const LessonPlanTrackingContextDecorator: Story["decorators"] = (Story) => ( + {}, + onClickRetry: () => {}, + onClickStartFromExample: () => {}, + onClickStartFromFreeText: () => {}, + onStreamFinished: () => {}, + onSubmitText: () => {}, + }} + > + + +); + +const meta: Meta = { + title: "Components/Chat/ChatQuickButtons", + component: ChatQuickButtons, + tags: ["autodocs"], + decorators: [ChatDecorator, LessonPlanTrackingContextDecorator], +}; + +export default meta; +type Story = StoryObj; + +export const Idle: Story = { + args: {}, + parameters: { + chatContext: { + ailaStreamingStatus: "Idle", + }, + }, +}; + +export const Loading: Story = { + args: {}, + parameters: { + chatContext: { + ailaStreamingStatus: "Loading", + }, + }, +}; + +export const LoadingWithoutMessages: Story = { + args: {}, + parameters: { + chatContext: { + ailaStreamingStatus: "Loading", + messages: [], + }, + }, +}; + +export const RequestMade: Story = { + args: {}, + parameters: { + chatContext: { + ailaStreamingStatus: "RequestMade", + }, + }, +}; + +export const StreamingLessonPlan: Story = { + args: {}, + parameters: { + chatContext: { + ailaStreamingStatus: "StreamingLessonPlan", + }, + }, +}; + +export const StreamingChatResponse: Story = { + args: {}, + parameters: { + chatContext: { + ailaStreamingStatus: "StreamingChatResponse", + }, + }, +}; + +export const Moderating: Story = { + args: {}, + parameters: { + chatContext: { + ailaStreamingStatus: "Moderating", + }, + }, +}; + +export const StreamingWithQueuedUserAction: Story = { + args: {}, + parameters: { + chatContext: { + queuedUserAction: "regenerate", + ailaStreamingStatus: "StreamingLessonPlan", + }, + }, +}; + +export const ModeratingWithQueuedUserAction: Story = { + args: {}, + parameters: { + chatContext: { + queuedUserAction: "regenerate", + ailaStreamingStatus: "Moderating", + }, + }, +}; diff --git a/apps/nextjs/src/components/AppComponents/Chat/chat-quick-buttons.tsx b/apps/nextjs/src/components/AppComponents/Chat/chat-quick-buttons.tsx index 5507dd44a..de37e5a74 100644 --- a/apps/nextjs/src/components/AppComponents/Chat/chat-quick-buttons.tsx +++ b/apps/nextjs/src/components/AppComponents/Chat/chat-quick-buttons.tsx @@ -12,16 +12,12 @@ import type { AilaStreamingStatus } from "./Chat/hooks/useAilaStreamingStatus"; import ChatButton from "./ui/chat-button"; import { IconRefresh, IconStop } from "./ui/icons"; -export type QuickActionButtonsProps = Readonly<{ - isEmptyScreen: boolean; -}>; - const shouldAllowStop = ( ailaStreamingStatus: AilaStreamingStatus, - isEmptyScreen: boolean, + hasMessages: boolean, queuedUserAction: string | null, ) => { - if (!isEmptyScreen) { + if (!hasMessages) { return false; } @@ -43,7 +39,7 @@ const shouldAllowStop = ( return false; }; -const QuickActionButtons = ({ isEmptyScreen }: QuickActionButtonsProps) => { +const QuickActionButtons = () => { const chat = useLessonChat(); const { trackEvent } = useAnalytics(); const lessonPlanTracking = useLessonPlanTracking(); @@ -57,6 +53,8 @@ const QuickActionButtons = ({ isEmptyScreen }: QuickActionButtonsProps) => { queuedUserAction, } = chat; + const hasMessages = !!messages.length; + const shouldAllowUserAction = ["Idle", "Moderating"].includes(ailaStreamingStatus) && !queuedUserAction; @@ -106,7 +104,7 @@ const QuickActionButtons = ({ isEmptyScreen }: QuickActionButtonsProps) => { {shouldAllowStop( ailaStreamingStatus, - isEmptyScreen, + hasMessages, queuedUserAction, ) && ( { onSubmit: (value: string) => void; - isEmptyScreen: boolean; - placeholder?: string; + hasMessages: boolean; ailaStreamingStatus: AilaStreamingStatus; queuedUserAction?: string | null; queueUserAction?: (action: string) => void; @@ -29,8 +28,7 @@ export function PromptForm({ onSubmit, input, setInput, - isEmptyScreen, - placeholder, + hasMessages, queuedUserAction, queueUserAction, }: Readonly) { @@ -93,8 +91,8 @@ export function PromptForm({ value={input} onChange={(e) => setInput(e.target.value)} placeholder={handlePlaceholder( - isEmptyScreen, - queuedUserAction ?? placeholder, + hasMessages, + queuedUserAction ?? undefined, )} spellCheck={false} className="min-h-[60px] w-full resize-none bg-transparent px-10 py-[1.3rem] text-base focus-within:outline-none" @@ -119,11 +117,14 @@ export function PromptForm({ ); } -function handlePlaceholder(isEmptyScreen: boolean, placeholder?: string) { - if (placeholder && !["continue", "regenerate"].includes(placeholder)) { - return placeholder; +function handlePlaceholder(hasMessages: boolean, queuedUserAction?: string) { + if ( + queuedUserAction && + !["continue", "regenerate"].includes(queuedUserAction) + ) { + return queuedUserAction; } - return !isEmptyScreen - ? "Type a subject, key stage and title" - : "Type your response here"; + return hasMessages + ? "Type your response here" + : "Type a subject, key stage and title"; } diff --git a/apps/nextjs/src/components/AppComponents/Chat/ui/textarea.tsx b/apps/nextjs/src/components/AppComponents/Chat/ui/textarea.tsx index e4eb3ebb2..4878582fd 100644 --- a/apps/nextjs/src/components/AppComponents/Chat/ui/textarea.tsx +++ b/apps/nextjs/src/components/AppComponents/Chat/ui/textarea.tsx @@ -2,23 +2,21 @@ import * as React from "react"; import { cn } from "@/lib/utils"; -interface TextareaProps - extends React.TextareaHTMLAttributes {} - -const Textarea = React.forwardRef( - ({ className, ...props }, ref) => { - return ( -