From 9a18fde0bfe5f70872e3f32d28d8c7d87ed8d0f4 Mon Sep 17 00:00:00 2001 From: nhyiramante1 Date: Wed, 17 Jun 2026 15:55:40 -0400 Subject: [PATCH 01/15] feat(frontend): remove Auth0, make Better Auth the default (Phase 2) Make Better Auth the default auth on every real-auth surface and delete Auth0. Demo mode stays (Google Docs runs in demo). Token now persists across refreshes. - authTokenStore.ts (new): guarded localStorage token persistence. - useDeviceAuth.ts: persist/clear + hydrate-on-mount (validate stored token, restore session without a fresh device flow). - appAuthContext.tsx: drop the Auth0 adapter + opt-in gate; Better Auth is the default, Demo for demo mode. - types.d.ts + editor APIs: replace EditorAPI.doLogin/doLogout(auth0Client) with a surface-specific openExternal(url) (Word: openBrowserWindow guarded; standalone/GDocs: window.open). Delete dead Auth0 code. - app/index.tsx: remove Auth0Provider; approval link uses editorAPI.openExternal; drop Microsoft/Facebook provider icons (Google only). - Remove popup.tsx/popup.html + their webpack entries; remove AUTH0 DefinePlugin entries from both webpack configs; remove @auth0/auth0-react. - privacypolicy.html: minimal wording + TODO for team review. No OpenAI route protection; no Word/Google Docs behavior change beyond auth. Co-Authored-By: Claude Opus 4.8 --- frontend/package-lock.json | 51 +------ frontend/package.json | 1 - .../src/api/__tests__/authTokenStore.test.ts | 70 +++++++++ frontend/src/api/authTokenStore.ts | 39 +++++ frontend/src/api/googleDocsEditorAPI.ts | 83 +---------- frontend/src/api/wordEditorAPI.ts | 133 ++---------------- frontend/src/contexts/appAuthContext.tsx | 77 ++-------- frontend/src/contexts/editorContext.tsx | 5 +- frontend/src/editor/index.tsx | 22 +-- frontend/src/hooks/useDeviceAuth.ts | 47 +++++-- frontend/src/index-gdocs.tsx | 4 +- frontend/src/pages/app/index.tsx | 64 +++------ frontend/src/popup.html | 23 --- frontend/src/popup.tsx | 62 -------- frontend/src/static/privacypolicy.html | 5 +- frontend/src/types.d.ts | 8 +- frontend/webpack.config.js | 14 -- frontend/webpack.google-docs.config.js | 2 - 18 files changed, 217 insertions(+), 493 deletions(-) create mode 100644 frontend/src/api/__tests__/authTokenStore.test.ts create mode 100644 frontend/src/api/authTokenStore.ts delete mode 100644 frontend/src/popup.html delete mode 100644 frontend/src/popup.tsx diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f3c9c397..9ba5ded4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,7 +10,6 @@ "license": "MIT", "dependencies": { "@ai-sdk/openai": "^2.0.0", - "@auth0/auth0-react": "^2.2.4", "@lexical/react": "^0.16.1", "@posthog/react": "^1.9.0", "@react-hook/window-size": "^3.1.1", @@ -271,30 +270,6 @@ "license": "MIT", "peer": true }, - "node_modules/@auth0/auth0-react": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@auth0/auth0-react/-/auth0-react-2.8.0.tgz", - "integrity": "sha512-f3KOkq+TW7AC3T+ZAo9G0hNL339z15C9q00QDVrMGCzZAPyp8lvDHKcAs21d/u+GzhU5zmssvJTQggDR7JqxSA==", - "license": "MIT", - "dependencies": { - "@auth0/auth0-spa-js": "^2.7.0" - }, - "peerDependencies": { - "react": "^16.11.0 || ^17 || ^18 || ^19", - "react-dom": "^16.11.0 || ^17 || ^18 || ^19" - } - }, - "node_modules/@auth0/auth0-spa-js": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@auth0/auth0-spa-js/-/auth0-spa-js-2.8.0.tgz", - "integrity": "sha512-Lu3dBius0CMRHNAWtw/RyIZH0b5B4jV9ZlVjpp5s7A11AO/XyABkNl0VW7Cz5ZHpAkXEba1CMnkxDG1/9LNIqg==", - "license": "MIT", - "dependencies": { - "browser-tabs-lock": "^1.2.15", - "dpop": "^2.1.1", - "es-cookie": "~1.3.2" - } - }, "node_modules/@azure/abort-controller": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz", @@ -9478,16 +9453,6 @@ "node": ">=8" } }, - "node_modules/browser-tabs-lock": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/browser-tabs-lock/-/browser-tabs-lock-1.3.0.tgz", - "integrity": "sha512-g6nHaobTiT0eMZ7jh16YpD2kcjAp+PInbiVq3M1x6KKaEIVhT4v9oURNIpZLOZ3LQbQ3XYfNhMAb/9hzNLIWrw==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "lodash": ">=4.17.21" - } - }, "node_modules/browserslist": { "version": "4.28.1", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", @@ -11231,15 +11196,6 @@ "node": ">=10" } }, - "node_modules/dpop": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/dpop/-/dpop-2.1.1.tgz", - "integrity": "sha512-J0Of2JTiM4h5si0tlbPQ/lkqfZ5wAEVkKYBhkwyyANnPJfWH4VsR5uIkZ+T+OSPIwDYUg1fbd5Mmodd25HjY1w==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -11475,12 +11431,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/es-cookie": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/es-cookie/-/es-cookie-1.3.2.tgz", - "integrity": "sha512-UTlYYhXGLOy05P/vKVT2Ui7WtC7NiRzGtJyAKKn32g5Gvcjn7KAClLPWlipCtxIus934dFg9o9jXiBL0nP+t9Q==", - "license": "MIT" - }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -15720,6 +15670,7 @@ "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, "license": "MIT" }, "node_modules/lodash.debounce": { diff --git a/frontend/package.json b/frontend/package.json index bb8102b5..b7f5f029 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -46,7 +46,6 @@ }, "dependencies": { "@ai-sdk/openai": "^2.0.0", - "@auth0/auth0-react": "^2.2.4", "@lexical/react": "^0.16.1", "@posthog/react": "^1.9.0", "@react-hook/window-size": "^3.1.1", diff --git a/frontend/src/api/__tests__/authTokenStore.test.ts b/frontend/src/api/__tests__/authTokenStore.test.ts new file mode 100644 index 00000000..d77da524 --- /dev/null +++ b/frontend/src/api/__tests__/authTokenStore.test.ts @@ -0,0 +1,70 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { clearToken, loadToken, persistToken } from '../authTokenStore'; + +// Minimal in-memory localStorage stub so the store can be tested in the node +// environment (no jsdom dependency). +function makeStorage() { + const map = new Map(); + return { + getItem: (k: string) => (map.has(k) ? map.get(k)! : null), + setItem: (k: string, v: string) => { + map.set(k, v); + }, + removeItem: (k: string) => { + map.delete(k); + }, + clear: () => map.clear(), + }; +} + +beforeEach(() => { + vi.stubGlobal('localStorage', makeStorage()); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('authTokenStore', () => { + it('persists and loads a token round-trip', () => { + expect(loadToken()).toBeNull(); + persistToken('tok-123'); + expect(loadToken()).toBe('tok-123'); + }); + + it('clears the token', () => { + persistToken('tok-123'); + clearToken(); + expect(loadToken()).toBeNull(); + }); + + it('degrades gracefully when setItem throws (no token persisted)', () => { + vi.stubGlobal('localStorage', { + ...makeStorage(), + setItem: () => { + throw new Error('storage disabled'); + }, + }); + expect(() => persistToken('tok-123')).not.toThrow(); + }); + + it('returns null when getItem throws', () => { + vi.stubGlobal('localStorage', { + ...makeStorage(), + getItem: () => { + throw new Error('storage disabled'); + }, + }); + expect(loadToken()).toBeNull(); + }); + + it('does not throw when removeItem throws', () => { + vi.stubGlobal('localStorage', { + ...makeStorage(), + removeItem: () => { + throw new Error('storage disabled'); + }, + }); + expect(() => clearToken()).not.toThrow(); + }); +}); diff --git a/frontend/src/api/authTokenStore.ts b/frontend/src/api/authTokenStore.ts new file mode 100644 index 00000000..4386f27e --- /dev/null +++ b/frontend/src/api/authTokenStore.ts @@ -0,0 +1,39 @@ +/** + * Persisted Better Auth token store. + * + * Keeps the device-flow access token across page refreshes so Better Auth can be the + * default auth without forcing a fresh interactive login on every reload. + * + * Every localStorage access is guarded: Office task panes and other embedded browsers + * can throw on storage access under some privacy/host settings. On failure we degrade to + * in-memory-only for the session (load returns null; persist/clear become no-ops) so the + * app keeps working. + * + * Security note: localStorage is XSS-exposed. This matches Auth0's prior + * `cacheLocation="localstorage"` posture, so it does not raise the existing risk level. + */ +const TOKEN_KEY = 'betterauth.token'; + +export function persistToken(token: string): void { + try { + localStorage.setItem(TOKEN_KEY, token); + } catch { + // Storage unavailable — fall back to in-memory for this session. + } +} + +export function loadToken(): string | null { + try { + return localStorage.getItem(TOKEN_KEY); + } catch { + return null; + } +} + +export function clearToken(): void { + try { + localStorage.removeItem(TOKEN_KEY); + } catch { + // No-op if storage is unavailable. + } +} diff --git a/frontend/src/api/googleDocsEditorAPI.ts b/frontend/src/api/googleDocsEditorAPI.ts index 88573f2a..14d60a53 100644 --- a/frontend/src/api/googleDocsEditorAPI.ts +++ b/frontend/src/api/googleDocsEditorAPI.ts @@ -8,8 +8,6 @@ * all document operations here go through Apps Script on Google's servers. */ -import type { Auth0ContextInterface } from '@auth0/auth0-react'; - // Declare the global GoogleAppsScript bridge (defined in sidebar.html) declare global { interface Window { @@ -111,82 +109,11 @@ function stopPolling() { */ export const googleDocsEditorAPI: EditorAPI = { /** - * Handles login for Google Docs. - * Since users are already authenticated with Google, we use their Google identity. - * For Auth0 integration, we could implement a popup flow similar to Word. + * Open a URL in a new browser tab. Google Docs runs in demo mode, so this is only + * used if the device-flow approval page is ever surfaced here. */ - async doLogin(auth0Client: Auth0ContextInterface): Promise { - // Option 1: Use Google identity directly (simpler) - // The user is already logged into Google, so we can use their email - // as the identifier and skip Auth0 entirely for Google Docs. - - // Option 2: Implement Auth0 popup flow (for consistency with Word) - // This would require opening a popup window and handling the OAuth flow. - - // For now, we'll use a simplified approach that works with Google identity - console.log( - 'Google Docs login: Using Google identity. Auth0 integration pending.', - ); - - // If Auth0 is required, we could implement a similar popup flow: - // 1. Open a popup to Auth0 login URL - // 2. Have the popup redirect back with tokens - // 3. Store tokens via Apps Script user properties - - // Placeholder: trigger Auth0 login if needed - try { - await auth0Client.loginWithRedirect({ - openUrl: (url: string) => { - // Open in a new window since we can't do redirects in an iframe - const popup = window.open( - url, - 'auth0-login', - 'width=500,height=600', - ); - - // Poll for completion (the popup should post a message when done) - const pollTimer = setInterval(() => { - if (popup?.closed) { - clearInterval(pollTimer); - // Check if we're now logged in - auth0Client.getAccessTokenSilently().catch(() => { - console.log('Auth0 login was cancelled or failed'); - }); - } - }, 500); - }, - }); - } catch (error) { - console.error('Auth0 login error:', error); - } - }, - - /** - * Handles logout for Google Docs. - */ - async doLogout(auth0Client: Auth0ContextInterface): Promise { - try { - await auth0Client.logout({ - openUrl: (url: string) => { - // Open logout URL in a popup - const popup = window.open( - url, - 'auth0-logout', - 'width=500,height=400', - ); - - // Close popup after a brief delay - setTimeout(() => { - popup?.close(); - }, 2000); - }, - logoutParams: { - returnTo: window.location.origin, - }, - }); - } catch (error) { - console.error('Auth0 logout error:', error); - } + openExternal(url: string): void { + window.open(url, '_blank', 'noopener'); }, /** @@ -246,7 +173,7 @@ export function isRunningInGoogleDocs(): boolean { /** * Gets the current user's email from Google. - * This can be used as a fallback identifier if Auth0 is not configured. + * Used as the identifier for the Google Docs surface (which runs in demo mode). */ export async function getGoogleUserEmail(): Promise { if (!isRunningInGoogleDocs()) { diff --git a/frontend/src/api/wordEditorAPI.ts b/frontend/src/api/wordEditorAPI.ts index 81202381..92833327 100644 --- a/frontend/src/api/wordEditorAPI.ts +++ b/frontend/src/api/wordEditorAPI.ts @@ -1,127 +1,14 @@ -import type { Auth0ContextInterface } from '@auth0/auth0-react'; - export const wordEditorAPI: EditorAPI = { - async doLogin(auth0Client: Auth0ContextInterface): Promise { - let dialog: Office.Dialog; - - // Strategy: the popup will pass its redirect-callback data here, so we can pass it on to handleRedirectCallback - const processMessage = ( - args: - | { message: string; origin: string | undefined } - | { error: number }, - ) => { - if ('error' in args) { - - console.error('Error:', args.error); - if (dialog) dialog.close(); - return; - } - const messageFromDialog = JSON.parse(args.message); - if (dialog) dialog.close(); - - if (messageFromDialog.status === 'success') { - // The dialog reported a successful login. - try { - auth0Client.handleRedirectCallback( - messageFromDialog.urlWithAuthInfo as string, - ); - } catch (error) { - - console.error( - 'auth0Client.handleRedirectCallback Error:', - error, - ); - } - } else { - - console.error('Login failed.', messageFromDialog); - } - }; - - await auth0Client.loginWithRedirect({ - openUrl: (url: string) => { - try { - const redirect = encodeURIComponent(url); - const bounceURL = - location.protocol + - '//' + - location.hostname + - (location.port ? ':' + location.port : '') + - '/popup.html?redirect=' + - redirect; - // height and width are percentages of the size of the screen. - // How MS use it: https://github.com/OfficeDev/Office-Add-in-samples/blob/main/Samples/auth/Office-Add-in-Microsoft-Graph-React/utilities/office-apis-helpers.ts#L38 - Office.context.ui.displayDialogAsync( - bounceURL, - { height: 45, width: 55 }, - (result) => { - dialog = result.value; - dialog.addEventHandler( - Office.EventType.DialogMessageReceived, - processMessage, - ); - }, - ); - } catch (error) { - - console.error('Error opening URL:', error); - } - }, - }); - }, - - async doLogout(auth0Client: Auth0ContextInterface): Promise { - let dialog: Office.Dialog; - - // Strategy: the popup will pass its redirect-callback data here, so we can pass it on to handleRedirectCallback - const processMessage = ( - args: - | { message: string; origin: string | undefined } - | { error: number }, - ) => { - dialog.close(); - if ('error' in args) { - - console.error('Error:', args.error); - return; - } - const messageFromDialog = JSON.parse(args.message); - - if (messageFromDialog.status === 'success') { - // The dialog reported a successful logout. - // It seems like we don't need to do anything here, since the auth0 client library has already cleared its cached credentials. - } else { - - console.error('Logout failed.', messageFromDialog); - } - }; - - await auth0Client.logout({ - openUrl: (url: string) => { - const redirect = encodeURIComponent(url); - const bounceURL = - location.protocol + - '//' + - location.hostname + - (location.port ? ':' + location.port : '') + - '/popup.html?redirect=' + - redirect; - Office.context.ui.displayDialogAsync( - bounceURL, - { height: 45, width: 55 }, - (result) => { - dialog = result.value; - dialog.addEventHandler( - Office.EventType.DialogMessageReceived, - processMessage, - ); - }, - ); - }, - logoutParams: { - returnTo: `${location.origin}/popup.html?logout=true`, - }, - }); + // Open the device-flow approval page in the system browser. Guarded so an Office + // host that doesn't expose openBrowserWindow fails explainably rather than silently. + openExternal(url: string): void { + if (Office?.context?.ui?.openBrowserWindow) { + Office.context.ui.openBrowserWindow(url); + } else { + throw new Error( + 'External browser login is not supported in this Office host.', + ); + } }, addSelectionChangeHandler: (handler: () => void) => { diff --git a/frontend/src/contexts/appAuthContext.tsx b/frontend/src/contexts/appAuthContext.tsx index 5380096d..e8ae870b 100644 --- a/frontend/src/contexts/appAuthContext.tsx +++ b/frontend/src/contexts/appAuthContext.tsx @@ -1,24 +1,17 @@ /** - * Provider-neutral auth session. + * App auth session. * - * `AppInner` consumes `useAppAuth()` instead of calling `useAuth0()` directly, so the - * visible login state (loading / authenticated / user / buttons) is decoupled from any - * one provider. Three adapters supply the same `AppAuthSession` shape: + * `AppInner` consumes `useAppAuth()` for all visible login state (loading / authenticated + * / user / buttons), decoupled from the auth implementation. Two providers supply the + * same `AppAuthSession` shape: * - * - Auth0AuthProvider (default everywhere; preserves current behavior) - * - BetterAuthProvider (opt-in: standalone editor + ?auth=betterauth) - * - DemoAuthProvider (OverallMode.demo) + * - BetterAuthProvider (default for every real-auth surface) + * - DemoAuthProvider (OverallMode.demo — Google Docs, no-backend dev) * * Hook-rule safety: we never call adapter hooks conditionally. `AppAuthProvider` chooses * WHICH provider component to render; each component unconditionally calls its own hooks * and is only mounted when selected. - * - * SCAFFOLDING: `Auth0AuthProvider`, `DemoAuthProvider`, and the `AppAuthProvider` - * selector are temporary compatibility shims that let Auth0 and Better Auth coexist - * during migration. Once Better Auth becomes the default, this collapses into a single - * Better Auth-only provider and the selector/adapters can be removed. */ -import { useAuth0 } from '@auth0/auth0-react'; import { useAtomValue } from 'jotai'; import { createContext, @@ -26,19 +19,17 @@ import { useMemo, type ReactNode, } from 'react'; -import { detectPlatform } from '@/api'; import { AccessTokenProvider } from '@/contexts/authTokenContext'; -import { EditorContext } from '@/contexts/editorContext'; import { OverallMode, overallModeAtom } from '@/contexts/pageContext'; import { useDeviceAuth } from '@/hooks/useDeviceAuth'; export interface AppAuthSession { - provider: 'auth0' | 'betterauth' | 'demo'; - isLoading: boolean; // initial session/provider loading only + provider: 'betterauth' | 'demo'; + isLoading: boolean; // initial session/provider loading (incl. token hydration) isAuthorizing: boolean; // device polling in progress (NOT isLoading) isAuthenticated: boolean; // token present AND user loaded user?: { name?: string; email?: string }; - error?: Error; // provider-level error (e.g. Auth0 error screen) + error?: Error; // provider-level error authorization?: { status: 'pending' | 'polling' | 'error'; userCode?: string; @@ -51,7 +42,7 @@ export interface AppAuthSession { } const DEFAULT_SESSION: AppAuthSession = { - provider: 'auth0', + provider: 'betterauth', isLoading: false, isAuthorizing: false, isAuthenticated: false, @@ -67,43 +58,6 @@ const AppAuthContext = createContext(DEFAULT_SESSION); export const useAppAuth = (): AppAuthSession => useContext(AppAuthContext); -/** True only on the standalone editor with the explicit opt-in query param. */ -function isBetterAuthOptIn(): boolean { - if (typeof window === 'undefined') return false; - if (detectPlatform() !== 'standalone') return false; - return new URLSearchParams(window.location.search).get('auth') === 'betterauth'; -} - -// --- Auth0 adapter ------------------------------------------------------------- - -function Auth0AuthProvider({ children }: { children: ReactNode }) { - const auth0 = useAuth0(); - const editorAPI = useContext(EditorContext); - - const session = useMemo( - () => ({ - provider: 'auth0', - isLoading: auth0.isLoading, - isAuthorizing: false, - isAuthenticated: auth0.isAuthenticated, - user: auth0.user - ? { name: auth0.user.name, email: auth0.user.email } - : undefined, - error: auth0.error, - getAccessToken: auth0.getAccessTokenSilently, - login: () => editorAPI.doLogin(auth0), - logout: () => editorAPI.doLogout(auth0), - }), - [auth0, editorAPI], - ); - - return ( - - {children} - - ); -} - // --- Better Auth adapter ------------------------------------------------------- function BetterAuthProvider({ children }: { children: ReactNode }) { @@ -130,7 +84,9 @@ function BetterAuthProvider({ children }: { children: ReactNode }) { return { provider: 'betterauth', - isLoading: false, + // Hydrating a persisted token on mount is initial session load, shown via the + // "Waiting" screen — NOT the device-code UI. + isLoading: device.status === 'hydrating', isAuthorizing, isAuthenticated: device.status === 'success' && !!device.token, user: device.user, @@ -182,17 +138,14 @@ function DemoAuthProvider({ children }: { children: ReactNode }) { // --- Selector + token bridge --------------------------------------------------- -/** Renders exactly one adapter provider by mode + opt-in. */ +/** Renders the demo provider in demo mode, Better Auth everywhere else. */ export function AppAuthProvider({ children }: { children: ReactNode }) { const mode = useAtomValue(overallModeAtom); if (mode === OverallMode.demo) { return {children}; } - if (isBetterAuthOptIn()) { - return {children}; - } - return {children}; + return {children}; } /** Feeds the selected session's getAccessToken into the existing token context. */ diff --git a/frontend/src/contexts/editorContext.tsx b/frontend/src/contexts/editorContext.tsx index 5fc98452..641f2880 100644 --- a/frontend/src/contexts/editorContext.tsx +++ b/frontend/src/contexts/editorContext.tsx @@ -2,8 +2,9 @@ import { createContext } from 'react'; // Provides editor API functionality through context export const EditorContext = createContext({ - doLogin: async () => {}, - doLogout: async () => {}, + openExternal: (url: string) => { + window.open(url, '_blank', 'noopener'); + }, getDocContext: () => new Promise((resolve) => resolve({ diff --git a/frontend/src/editor/index.tsx b/frontend/src/editor/index.tsx index 1ce35295..0f5f65bd 100644 --- a/frontend/src/editor/index.tsx +++ b/frontend/src/editor/index.tsx @@ -3,7 +3,6 @@ import { createRoot } from 'react-dom/client'; import { OverallMode, overallModeAtom } from '@/contexts/pageContext'; import * as SidebarInner from '@/pages/app'; -import type { Auth0ContextInterface } from '@auth0/auth0-react'; import { useAtomValue, useSetAtom } from 'jotai'; import LexicalEditor from './editor'; import './styles.css'; @@ -44,25 +43,8 @@ export function EditorScreen({ }; const editorAPI: EditorAPI = useMemo(() => ({ - doLogin: async (auth0Client: Auth0ContextInterface) => { - try { - await auth0Client.loginWithPopup(); - } catch (error) { - - console.error('auth0Client.loginWithPopup Error:', error); - } - }, - doLogout: async (auth0Client: Auth0ContextInterface) => { - try { - await auth0Client.logout({ - logoutParams: { - returnTo: `${location.origin}/editor.html`, - }, - }); - } catch (error) { - - console.error('auth0Client.logout Error:', error); - } + openExternal: (url: string) => { + window.open(url, '_blank', 'noopener'); }, getDocContext: async (): Promise => { return Promise.resolve(docContextRef.current); diff --git a/frontend/src/hooks/useDeviceAuth.ts b/frontend/src/hooks/useDeviceAuth.ts index d5e17f54..9adf3005 100644 --- a/frontend/src/hooks/useDeviceAuth.ts +++ b/frontend/src/hooks/useDeviceAuth.ts @@ -1,9 +1,11 @@ /** * React hook driving the interactive Better Auth device flow. * - * Owns the state machine and an in-memory access token (never persisted). Polling is - * cancellable: reset(), logout(), unmount, or a fresh start() abort the in-flight loop - * via an AbortController so a stale tick cannot deliver a token after the user leaves. + * Owns the state machine and the access token. The token is persisted (guarded + * localStorage) so a page refresh restores the session via hydrate-on-mount instead of + * forcing a fresh interactive login. Polling is cancellable: reset(), logout(), unmount, + * or a fresh start() abort the in-flight loop via an AbortController. The abort signal is + * the single source of truth for cancellation, so no mounted flag is needed. */ import { useCallback, useEffect, useRef, useState } from 'react'; import { @@ -14,9 +16,11 @@ import { requestDeviceCode, signOut as signOutRequest, } from '@/api/deviceAuth'; +import { clearToken, loadToken, persistToken } from '@/api/authTokenStore'; export type DeviceAuthStatus = | 'idle' + | 'hydrating' // validating a persisted token on mount | 'pending' // requesting the device code | 'polling' // waiting for the user to approve in the browser | 'success' @@ -65,6 +69,7 @@ export function useDeviceAuth(): UseDeviceAuth { const reset = useCallback(() => { abortInFlight(); tokenRef.current = undefined; + clearToken(); setState(INITIAL); }, [abortInFlight]); @@ -132,6 +137,7 @@ export function useDeviceAuth(): UseDeviceAuth { controller.signal, ); tokenRef.current = result.accessToken; + persistToken(result.accessToken); safeSet(controller, { status: 'success', token: result.accessToken, @@ -154,23 +160,48 @@ export function useDeviceAuth(): UseDeviceAuth { if (token) { await signOutRequest(token); } - } catch { + } catch { // Best-effort, ignore server sign-out failure. Clear local state regardless. } finally { - // Clear local state regardless of sign-out success, since the token is the only proof of auth. + // Clear local state regardless of sign-out success, since the token is the + // only proof of auth. tokenRef.current = undefined; + clearToken(); setState(INITIAL); } }, [abortInFlight]); useEffect(() => { - // Abort any in-flight device flow when the component unmounts. - // The signal is the source of truth so no mounted flag is needed. + const controller = new AbortController(); + abortRef.current = controller; + + // Hydrate: if a token was persisted, validate it instead of forcing a new login. + const stored = loadToken(); + if (stored) { + setState({ status: 'hydrating' }); + fetchUserInfo(stored, controller.signal) + .then((user) => { + tokenRef.current = stored; + safeSet(controller, { + status: 'success', + token: stored, + user, + }); + }) + .catch(() => { + if (controller.signal.aborted) return; + // Stale/invalid token — drop it and fall back to login. + clearToken(); + safeSet(controller, { status: 'idle' }); + }); + } + + // Abort any in-flight device flow / hydration when the component unmounts. return () => { abortRef.current?.abort(); abortRef.current = null; }; - }, []); + }, [safeSet]); return { ...state, start, reset, logout }; } diff --git a/frontend/src/index-gdocs.tsx b/frontend/src/index-gdocs.tsx index 7fc84bc2..2d8a34ec 100644 --- a/frontend/src/index-gdocs.tsx +++ b/frontend/src/index-gdocs.tsx @@ -4,7 +4,7 @@ * Unlike the Word add-in (index.tsx), this doesn't require Office.onReady. * It initializes immediately when loaded in the Google Docs sidebar. * - * Uses demo mode to bypass Auth0 since users are already authenticated with Google. + * Uses demo mode (no interactive sign-in) since users are already authenticated with Google. */ import { createRoot } from 'react-dom/client'; import { StrictMode } from 'react'; @@ -26,7 +26,7 @@ declare global { } } -// Create a Jotai store with demo mode pre-set to bypass Auth0 +// Create a Jotai store with demo mode pre-set (no interactive sign-in) const store = createStore(); store.set(overallModeAtom, OverallMode.demo); diff --git a/frontend/src/pages/app/index.tsx b/frontend/src/pages/app/index.tsx index 64f1ffe4..1dc13aa6 100644 --- a/frontend/src/pages/app/index.tsx +++ b/frontend/src/pages/app/index.tsx @@ -1,15 +1,15 @@ -import { Auth0Provider } from '@auth0/auth0-react'; import { PostHogProvider, PostHogErrorBoundary } from '@posthog/react'; import { useWindowSize } from '@react-hook/window-size/throttled'; import { useAtomValue } from 'jotai'; -import { useState } from 'react'; -import { CgFacebook, CgGoogle, CgMicrosoft } from 'react-icons/cg'; +import { useContext, useState } from 'react'; +import { CgGoogle } from 'react-icons/cg'; import { AppAuthProvider, AppAuthTokenBridge, useAppAuth, } from '@/contexts/appAuthContext'; import { useAccessToken } from '@/contexts/authTokenContext'; +import { EditorContext } from '@/contexts/editorContext'; import ChatContextWrapper from '@/contexts/chatContext'; import { OverallMode, @@ -32,7 +32,8 @@ const POSTHOG_HOST = 'https://e.thoughtful-ai.com/'; const POSTHOG_ENABLED = true; // Device-flow status surfaced during Better Auth sign-in. Shows the user code and a -// user-clicked link that opens the approval page in a new tab (no focus-stealing). +// button that opens the approval page in the system browser via the surface-specific +// EditorAPI.openExternal (Word: openBrowserWindow; standalone/GDocs: new tab). // Rendered inside the not-logged-in screen, NOT gated by the isLoading "Waiting" screen. function DeviceAuthStatus({ authorization, @@ -44,6 +45,8 @@ function DeviceAuthStatus({ error?: string; }; }) { + const editorAPI = useContext(EditorContext); + if (!authorization) return null; if (authorization.status === 'error') { @@ -87,22 +90,15 @@ function DeviceAuthStatus({

{authorization.verificationUri ? (

- { + editorAPI.openExternal(authorization.verificationUri!); }} > Open approval page → - +

) : null}

Open the approval page and enter the code above to continue.

@@ -232,11 +228,9 @@ function AppInner() {
-

Available Auth Providers

+

Sign in with Google

- -
- - - - - - - + + + + + diff --git a/frontend/src/popup.html b/frontend/src/popup.html deleted file mode 100644 index 4d59c452..00000000 --- a/frontend/src/popup.html +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - - - - Thoughtful - - - - - - - -
- - - diff --git a/frontend/src/popup.tsx b/frontend/src/popup.tsx deleted file mode 100644 index f2c7ae1c..00000000 --- a/frontend/src/popup.tsx +++ /dev/null @@ -1,62 +0,0 @@ -Office.onReady(() => { - // From https://github.com/OfficeDev/Office-Add-in-Auth0/blob/master/Scripts/popup.js - // and https://github.com/OfficeDev/Office-Add-in-Auth0/blob/master/Scripts/popupRedirect.js - // (kca simplified this to just use a single page) - const DEBUG = false; - const searchParams = new URLSearchParams(window.location.search); - const auth0Domain = process.env.AUTH0_DOMAIN; - const redirect = searchParams.get('redirect'); - function constructValidatedURL(url: URL) { - if (url.hostname !== auth0Domain) { - return null; - } - const validatedURL = new URL(`https://${auth0Domain}`); - if (url.pathname === '/authorize' || url.pathname === '/v2/logout') { - validatedURL.pathname = url.pathname; - validatedURL.search = url.search; - } - return validatedURL; - } - if (redirect) { - const validatedURL = constructValidatedURL(new URL(redirect)); - if (!validatedURL) { - document.body.innerText = `Invalid redirect URL: ${redirect}`; - return; - } - if (DEBUG) { - document.body.innerText = `Redirecting to ${validatedURL.href}`; - } - setTimeout( - () => { - window.location.href = validatedURL.href; - }, - DEBUG ? 5000 : 0, - ); - } else { - // Note: this will also get called with `logout=true` in the logout flow, but - // the only thing we need to do here is message the parent to get the dialog to close, - // so it's fine to take the same action in both cases. - const message = { - status: 'success', - urlWithAuthInfo: window.location.href, - }; - if (DEBUG) { - document.body.innerText = `Messaging parent with ${JSON.stringify( - message, - )}`; - } - setTimeout( - () => { - if (Office.context && Office.context.ui) { - Office.context.ui.messageParent(JSON.stringify(message)); - } else { - - console.error( - 'Could not message parent: Office.context.ui is undefined', - ); - } - }, - DEBUG ? 5000 : 0, - ); - } -}); diff --git a/frontend/src/static/privacypolicy.html b/frontend/src/static/privacypolicy.html index eeff047f..ee4a0c46 100644 --- a/frontend/src/static/privacypolicy.html +++ b/frontend/src/static/privacypolicy.html @@ -200,7 +200,8 @@

Usage Data

Account Information

    -
  • Basic authentication information provided through Auth0
  • + +
  • Basic authentication information provided through our authentication provider
  • Email address (if you choose to provide it for support purposes)

How We Use Your Information

@@ -253,7 +254,7 @@

Service Providers

  • Cloud computing infrastructure
  • AI processing capabilities
  • -
  • Authentication (Auth0)
  • +
  • Authentication (Google sign-in)
  • Microsoft Office Add-in platform
  • All service providers are bound by strict confidentiality agreements
  • diff --git a/frontend/src/types.d.ts b/frontend/src/types.d.ts index d79a06ca..a8e600b2 100644 --- a/frontend/src/types.d.ts +++ b/frontend/src/types.d.ts @@ -23,8 +23,12 @@ interface SavedItem { } interface EditorAPI { - doLogin(auth0Client: Auth0ContextInterface): Promise; - doLogout(auth0Client: Auth0ContextInterface): Promise; + /** + * Open a URL in the user's external browser. Surface-specific: Word uses + * Office.context.ui.openBrowserWindow; standalone/Google Docs use window.open. + * Used to open the Better Auth device-flow approval page. + */ + openExternal(url: string): void; getDocContext(this: void): Promise; addSelectionChangeHandler: (handler: () => void) => void; removeSelectionChangeHandler: (handler: () => void) => void; diff --git a/frontend/webpack.config.js b/frontend/webpack.config.js index 2e1a9a62..fc2cb9ce 100644 --- a/frontend/webpack.config.js +++ b/frontend/webpack.config.js @@ -37,13 +37,6 @@ module.exports = async (env = {}, options = {}) => { import: ['./src/logs/index.tsx', './src/logs/logs.html'], dependOn: 'react' }, - popup: { - import: [ - './src/popup.tsx', - './src/popup.html' - ], - dependOn: 'react' - }, editor: { import: ['./src/editor/index.tsx', './src/editor/editor.html'], dependOn: 'react' @@ -144,11 +137,6 @@ module.exports = async (env = {}, options = {}) => { template: './src/logs/logs.html', chunks: ['logs', 'react'] }), - new HtmlWebpackPlugin({ - filename: 'popup.html', - template: './src/popup.html', - chunks: ['polyfill', 'popup', 'react'] - }), new HtmlWebpackPlugin({ filename: 'commands.html', template: './src/commands/commands.html', @@ -158,8 +146,6 @@ module.exports = async (env = {}, options = {}) => { Promise: ['es6-promise', 'Promise'] }), new webpack.DefinePlugin({ - 'process.env.AUTH0_DOMAIN': JSON.stringify('dev-rbroo1fvav24wamu.us.auth0.com'), - 'process.env.AUTH0_CLIENT_ID': JSON.stringify('YZhokQZRgE2YUqU5Is9LcaMiCzujoaVr'), // Device-flow client ID; must match a value in the backend's // BETTER_AUTH_DEVICE_CLIENT_IDS allowlist. Override via env for other envs. 'process.env.BETTER_AUTH_DEVICE_CLIENT_ID': JSON.stringify( diff --git a/frontend/webpack.google-docs.config.js b/frontend/webpack.google-docs.config.js index a33ab350..68c6ac08 100644 --- a/frontend/webpack.google-docs.config.js +++ b/frontend/webpack.google-docs.config.js @@ -53,8 +53,6 @@ module.exports = (_env = {}, options = {}) => { filename: '[name].css' }), new webpack.DefinePlugin({ - 'process.env.AUTH0_DOMAIN': JSON.stringify('dev-rbroo1fvav24wamu.us.auth0.com'), - 'process.env.AUTH0_CLIENT_ID': JSON.stringify('YZhokQZRgE2YUqU5Is9LcaMiCzujoaVr'), 'process.env.NODE_ENV': JSON.stringify(options.mode || 'development'), // Backend origin for the Google Docs sidebar. Empty in dev (the sidebar // reaches the backend through this dev server's /api proxy); in prod the From 46c73e8e394e9967fe5d54935f907338ce8a7041 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 17:01:48 +0000 Subject: [PATCH 02/15] Add "My Words" collaborative editor (v1, standalone) A new sidebar page where the AI can edit the document but may never originate words. Every word it places must be lifted from the writer's own corpus (document + scratchpad + their chat messages), joined only by punctuation and a small closed set of glue words. - corpus.ts: pure, unit-tested phrase-level validator (buildCorpus + validateText + GLUE_WORDS). Lifted spans must appear verbatim in the corpus; new content-word adjacencies are illegal unless glue-bridged. - my-words page: AI tool loop (view / str_replace / insert / highlight) via the ai SDK. Inserted text is validated against a freshly assembled corpus before being applied; rejections are fed back to the model so it retries or asks the writer. AI speech shows as an ephemeral caption with no scrollback; the scratchpad takes most of the height. The model gets lightweight activity signals rather than a full-document dump. - EditorAPI gains getDocText + applyEdit, implemented over Lexical in the standalone editor; Word/Google Docs get getDocText plus typed applyEdit TODO stubs so the same page can drive those hosts later. Wired into the existing page nav (pageContext, navbar, app). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XtzcT6Yo9dVoCHsWgNMQ6U --- frontend/src/api/googleDocsEditorAPI.ts | 15 + frontend/src/api/wordEditorAPI.ts | 19 ++ frontend/src/components/navbar/index.tsx | 1 + frontend/src/contexts/editorContext.tsx | 5 + frontend/src/contexts/pageContext.tsx | 1 + frontend/src/editor/editor.tsx | 105 ++++++ frontend/src/editor/index.tsx | 60 +++- frontend/src/pages/app/index.tsx | 3 + .../pages/my-words/__tests__/corpus.test.ts | 115 +++++++ frontend/src/pages/my-words/corpus.ts | Bin 0 -> 5967 bytes frontend/src/pages/my-words/index.tsx | 319 ++++++++++++++++++ frontend/src/pages/my-words/styles.module.css | 132 ++++++++ frontend/src/types.d.ts | 12 + 13 files changed, 782 insertions(+), 5 deletions(-) create mode 100644 frontend/src/pages/my-words/__tests__/corpus.test.ts create mode 100644 frontend/src/pages/my-words/corpus.ts create mode 100644 frontend/src/pages/my-words/index.tsx create mode 100644 frontend/src/pages/my-words/styles.module.css diff --git a/frontend/src/api/googleDocsEditorAPI.ts b/frontend/src/api/googleDocsEditorAPI.ts index 88573f2a..774d1291 100644 --- a/frontend/src/api/googleDocsEditorAPI.ts +++ b/frontend/src/api/googleDocsEditorAPI.ts @@ -231,6 +231,21 @@ export const googleDocsEditorAPI: EditorAPI = { throw new Error('Phrase not found'); } }, + + /** Full document text, used for the corpus and the `view` tool. */ + async getDocText(): Promise { + const ctx = await window.GoogleAppsScript.getDocContext(); + return `${ctx.beforeCursor || ''}${ctx.selectedText || ''}${ctx.afterCursor || ''}`; + }, + + // TODO(my-words): bridge to Apps Script (selectPhrase + replaceSelection for + // str_replace; insertTextAtCursor for insert). The GDocs multi-tab corpus + // (getAllTabs) is the exciting follow-up. Deferred — v1 targets standalone. + applyEdit(_edit: DocEdit): Promise { + return Promise.reject( + new Error('applyEdit is not implemented for Google Docs yet'), + ); + }, }; /** diff --git a/frontend/src/api/wordEditorAPI.ts b/frontend/src/api/wordEditorAPI.ts index 81202381..3d9bdcc9 100644 --- a/frontend/src/api/wordEditorAPI.ts +++ b/frontend/src/api/wordEditorAPI.ts @@ -212,4 +212,23 @@ export const wordEditorAPI: EditorAPI = { } }); }, + + /** Full document text, used for the corpus and the `view` tool. */ + async getDocText(): Promise { + return Word.run(async (context: Word.RequestContext) => { + const body = context.document.body; + context.load(body, 'text'); + await context.sync(); + return body.text.replace(/\r/g, '\n'); + }); + }, + + // TODO(my-words): implement for Word via Office.js body.search + replace + // (str_replace) and range.insertText (insert). Deferred — v1 targets the + // standalone editor only. + applyEdit(_edit: DocEdit): Promise { + return Promise.reject( + new Error('applyEdit is not implemented for Word yet'), + ); + }, }; diff --git a/frontend/src/components/navbar/index.tsx b/frontend/src/components/navbar/index.tsx index 05147e89..660e726c 100644 --- a/frontend/src/components/navbar/index.tsx +++ b/frontend/src/components/navbar/index.tsx @@ -24,6 +24,7 @@ const pageNames: Page[] = [ { name: PageName.Draft, title: 'Draft', hint: 'Generate suggestions' }, { name: PageName.Revise, title: 'Revise', hint: 'Improve your text' }, { name: PageName.Chat, title: 'Chat', hint: 'Ask about your doc' }, + { name: PageName.MyWords, title: 'My Words', hint: 'Shape your own words' }, ]; export default function Navbar() { diff --git a/frontend/src/contexts/editorContext.tsx b/frontend/src/contexts/editorContext.tsx index 5fc98452..34202b71 100644 --- a/frontend/src/contexts/editorContext.tsx +++ b/frontend/src/contexts/editorContext.tsx @@ -18,4 +18,9 @@ export const EditorContext = createContext({ console.warn('selectPhrase is not implemented yet'); return new Promise((resolve) => resolve()); }, + getDocText: () => Promise.resolve(''), + applyEdit: () => { + console.warn('applyEdit is not implemented yet'); + return Promise.resolve(); + }, }); diff --git a/frontend/src/contexts/pageContext.tsx b/frontend/src/contexts/pageContext.tsx index 0c9f902e..bce6bcb6 100644 --- a/frontend/src/contexts/pageContext.tsx +++ b/frontend/src/contexts/pageContext.tsx @@ -5,6 +5,7 @@ export enum PageName { Chat = 'chat', Draft = 'draft', TagLinker = 'tag-linker', + MyWords = 'my-words', } export enum OverallMode { diff --git a/frontend/src/editor/editor.tsx b/frontend/src/editor/editor.tsx index ffee53b5..0e41f320 100644 --- a/frontend/src/editor/editor.tsx +++ b/frontend/src/editor/editor.tsx @@ -6,21 +6,122 @@ import { type InitialEditorStateType, LexicalComposer, } from '@lexical/react/LexicalComposer'; +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; import { ContentEditable } from '@lexical/react/LexicalContentEditable'; import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'; import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin'; import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin'; import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'; import { + $createParagraphNode, + $createRangeSelection, + $createTextNode, $getRoot, $getSelection, $isRangeSelection, + $isTextNode, + $setSelection, type ElementNode, type LexicalNode, + type TextNode, } from 'lexical'; +import { useEffect } from 'react'; import classes from './editor.module.css'; +/** + * Imperative handle the "My Words" page uses to read and edit the standalone + * Lexical document. Mirrors the host-agnostic operations on EditorAPI; Word and + * Google Docs implement the same shape with their native APIs. + */ +export interface EditorControls { + getText: () => string; + /** Replace the whole document with plain text (paragraphs split on \n). */ + setText: (text: string) => void; + /** Select the first occurrence of `phrase` within a single paragraph. */ + selectPhrase: (phrase: string) => boolean; +} + +/** + * Lives inside LexicalComposer so it can grab the editor instance and hand a + * small imperative control surface back up to the EditorScreen. + */ +function ControlsPlugin({ + onReady, +}: { + onReady?: (controls: EditorControls) => void; +}) { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + if (!onReady) return; + + const controls: EditorControls = { + getText: () => + editor + .getEditorState() + .read(() => $getRoot().getTextContent()), + + setText: (text: string) => { + editor.update(() => { + const root = $getRoot(); + root.clear(); + for (const line of text.split('\n')) { + const paragraph = $createParagraphNode(); + if (line.length > 0) { + paragraph.append($createTextNode(line)); + } + root.append(paragraph); + } + }); + }, + + selectPhrase: (phrase: string) => { + let found = false; + editor.update(() => { + const textNodes: TextNode[] = []; + const collect = (node: LexicalNode) => { + if ($isTextNode(node)) { + textNodes.push(node); + } else if ('getChildren' in node) { + for (const child of ( + node as ElementNode + ).getChildren()) { + collect(child); + } + } + }; + collect($getRoot()); + + const needle = phrase.toLowerCase(); + for (const node of textNodes) { + const idx = node + .getTextContent() + .toLowerCase() + .indexOf(needle); + if (idx === -1) continue; + const selection = $createRangeSelection(); + selection.anchor.set(node.getKey(), idx, 'text'); + selection.focus.set( + node.getKey(), + idx + phrase.length, + 'text', + ); + $setSelection(selection); + found = true; + return; + } + }); + return found; + }, + }; + + onReady(controls); + }, [editor, onReady]); + + return null; +} + function $getDocContext(): DocContext { // Initialize default empty context const docContext: DocContext = { @@ -156,11 +257,13 @@ function LexicalEditor({ initialState, storageKey = 'doc', preamble, + onReady, }: { updateDocContext: (docContext: DocContext) => void; initialState: InitialEditorStateType | null; storageKey?: string; preamble?: JSX.Element; + onReady?: (controls: EditorControls) => void; }) { return ( + +
    diff --git a/frontend/src/editor/index.tsx b/frontend/src/editor/index.tsx index 1ce35295..72db2bad 100644 --- a/frontend/src/editor/index.tsx +++ b/frontend/src/editor/index.tsx @@ -1,11 +1,11 @@ -import { useRef, useState, StrictMode, useMemo } from 'react'; +import { useCallback, useRef, useState, StrictMode, useMemo } from 'react'; import { createRoot } from 'react-dom/client'; import { OverallMode, overallModeAtom } from '@/contexts/pageContext'; import * as SidebarInner from '@/pages/app'; import type { Auth0ContextInterface } from '@auth0/auth0-react'; import { useAtomValue, useSetAtom } from 'jotai'; -import LexicalEditor from './editor'; +import LexicalEditor, { type EditorControls } from './editor'; import './styles.css'; import classes from './styles.module.css'; import { EditorContext } from '@/contexts/editorContext'; @@ -33,6 +33,12 @@ export function EditorScreen({ afterCursor: '', }); + // Imperative handle into the Lexical document, populated once it mounts. + const controlsRef = useRef(null); + const handleEditorReady = useCallback((controls: EditorControls) => { + controlsRef.current = controls; + }, []); + // Since this is a list, a useState would have worked as well const selectionChangeHandlers = useRef<(() => void)[]>([]); @@ -78,9 +84,52 @@ export function EditorScreen({ else console.warn('Handler not found'); }, - selectPhrase(_text) { - console.warn('selectPhrase is not implemented yet'); - return new Promise((resolve) => resolve()); + selectPhrase(text) { + const found = controlsRef.current?.selectPhrase(text) ?? false; + return found + ? Promise.resolve() + : Promise.reject(new Error('Phrase not found')); + }, + getDocText: (): Promise => { + return Promise.resolve(controlsRef.current?.getText() ?? ''); + }, + applyEdit: (edit: DocEdit): Promise => { + const controls = controlsRef.current; + if (!controls) { + return Promise.reject(new Error('Editor is not ready yet')); + } + const current = controls.getText(); + + let next: string; + if (edit.type === 'str_replace') { + const idx = current.indexOf(edit.oldStr); + if (idx === -1) { + throw new Error( + `Could not find the text to replace: "${edit.oldStr}"`, + ); + } + next = + current.slice(0, idx) + + edit.newStr + + current.slice(idx + edit.oldStr.length); + } else if (edit.after !== undefined && edit.after !== '') { + const idx = current.indexOf(edit.after); + if (idx === -1) { + throw new Error( + `Could not find the anchor text: "${edit.after}"`, + ); + } + const at = idx + edit.after.length; + next = current.slice(0, at) + edit.text + current.slice(at); + } else { + // No anchor: insert at the current cursor / after the selection. + const { beforeCursor, selectedText, afterCursor } = + docContextRef.current; + next = beforeCursor + selectedText + edit.text + afterCursor; + } + + controls.setText(next); + return Promise.resolve(); }, }), []); @@ -131,6 +180,7 @@ export function EditorScreen({ updateDocContext={docUpdated} storageKey={getStorageKey()} preamble={editorPreamble} + onReady={handleEditorReady} /> {isDemo ? (
    diff --git a/frontend/src/pages/app/index.tsx b/frontend/src/pages/app/index.tsx index 64f1ffe4..1120cac5 100644 --- a/frontend/src/pages/app/index.tsx +++ b/frontend/src/pages/app/index.tsx @@ -20,6 +20,7 @@ import { import { OnboardingCarousel } from '../carousel/OnboardingCarousel'; import Chat from '../chat'; import Draft from '../draft'; +import MyWords from '../my-words'; import Revise from '../revise'; import classes from './styles.module.css'; import Navbar from '@/components/navbar'; @@ -317,6 +318,8 @@ function AppInner() { return ; case PageName.Draft: return ; + case PageName.MyWords: + return ; } return null; } diff --git a/frontend/src/pages/my-words/__tests__/corpus.test.ts b/frontend/src/pages/my-words/__tests__/corpus.test.ts new file mode 100644 index 00000000..adb7a23d --- /dev/null +++ b/frontend/src/pages/my-words/__tests__/corpus.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest'; + +import { buildCorpus, GLUE_WORDS, validateText } from '../corpus'; + +const corpusOf = (text: string) => buildCorpus({ docText: text }); + +describe('buildCorpus', () => { + it('collects words from all sources, dropping punctuation', () => { + const corpus = buildCorpus({ + docText: 'The cat sat.', + scratchpad: 'a quiet morning', + userMessages: ['I like dogs too'], + }); + expect(corpus.wordSet.has('cat')).toBe(true); + expect(corpus.wordSet.has('morning')).toBe(true); + expect(corpus.wordSet.has('dogs')).toBe(true); + expect(corpus.wordSet.has('.')).toBe(false); + }); + + it('does not let phrases span across separate sources', () => { + // "sat" ends docText and "dog" begins scratchpad; their adjacency is an + // artifact of concatenation and must not count as a corpus phrase. + const corpus = buildCorpus({ docText: 'cat sat', scratchpad: 'dog run' }); + expect(validateText('cat sat', corpus).ok).toBe(true); + expect(validateText('sat dog', corpus).ok).toBe(false); + }); +}); + +describe('validateText — phrase-level rule', () => { + it('accepts a verbatim phrase lifted from the corpus', () => { + const corpus = corpusOf('The quick brown fox jumped over the lazy dog.'); + expect(validateText('the quick brown fox', corpus).ok).toBe(true); + expect(validateText('lazy dog', corpus).ok).toBe(true); + }); + + it('accepts two corpus phrases bridged by a glue word', () => { + const corpus = corpusOf('I value honesty. I also value hard work.'); + // "honesty" and "hard work" are both in the corpus, bridged by "and". + expect(validateText('honesty and hard work', corpus).ok).toBe(true); + }); + + it('accepts punctuation inserted freely between lifted content', () => { + const corpus = corpusOf('honesty hard work'); + expect(validateText('honesty, hard work', corpus).ok).toBe(true); + expect(validateText('honesty: hard work!', corpus).ok).toBe(true); + }); + + it('rejects a new adjacency of two corpus words with no bridge', () => { + // "big" and "dog" both appear, but never adjacent and not glue-bridged. + const corpus = corpusOf('the big cat and the small dog'); + expect(validateText('big dog', corpus).ok).toBe(false); + // The glue-bridged version is allowed. + expect(validateText('big and dog', corpus).ok).toBe(true); + }); + + it('rejects a multi-word run that is not contiguous in the corpus', () => { + // Each bigram exists ("a b", "b c") but the trigram "a b c" never does. + const corpus = corpusOf('alpha beta. beta gamma.'); + expect(validateText('alpha beta', corpus).ok).toBe(true); + expect(validateText('beta gamma', corpus).ok).toBe(true); + expect(validateText('alpha beta gamma', corpus).ok).toBe(false); + }); + + it('rejects a novel content word the writer never used', () => { + const corpus = corpusOf('I enjoy writing essays.'); + const result = validateText('I enjoy painting', corpus); + expect(result.ok).toBe(false); + expect(result.offending).toContain('painting'); + }); + + it('allows a punctuation-only / glue-only edit', () => { + const corpus = corpusOf('anything at all'); + expect(validateText('.', corpus).ok).toBe(true); + expect(validateText('and', corpus).ok).toBe(true); + expect(validateText(' , ; — ', corpus).ok).toBe(true); + }); + + it('is case-insensitive when matching', () => { + const corpus = corpusOf('Reproducible Research Matters'); + expect(validateText('reproducible research', corpus).ok).toBe(true); + expect(validateText('REPRODUCIBLE RESEARCH', corpus).ok).toBe(true); + }); + + it('matches words with internal apostrophes and straightens curly quotes', () => { + const corpus = corpusOf("I don't think that's wise"); + expect(validateText("don't", corpus).ok).toBe(true); + // curly apostrophe in the proposed text should still match. + expect(validateText('don’t', corpus).ok).toBe(true); + }); + + it('reports a segmentation that labels lifted / glue / punct parts', () => { + const corpus = corpusOf('honesty hard work'); + const result = validateText('honesty and hard work, please', corpus); + // "please" is novel content => not ok. + expect(result.ok).toBe(false); + const kinds = result.segments.map((s) => s.kind); + expect(kinds).toContain('lifted'); + expect(kinds).toContain('glue'); + expect(kinds).toContain('punct'); + }); + + it('treats an empty proposal as trivially valid', () => { + const corpus = corpusOf('whatever'); + expect(validateText('', corpus).ok).toBe(true); + }); +}); + +describe('GLUE_WORDS', () => { + it('includes basic articles/conjunctions but excludes content connectives', () => { + expect(GLUE_WORDS.has('and')).toBe(true); + expect(GLUE_WORDS.has('the')).toBe(true); + expect(GLUE_WORDS.has('because')).toBe(false); + expect(GLUE_WORDS.has('however')).toBe(false); + }); +}); diff --git a/frontend/src/pages/my-words/corpus.ts b/frontend/src/pages/my-words/corpus.ts new file mode 100644 index 0000000000000000000000000000000000000000..ab1429e23c347855c23fb1c1227e110fa77b6b24 GIT binary patch literal 5967 zcmbtY+j85;5zQ<46&>yaL7D=+kD;wqDY7c1T3@zgIo?E3)vBIJog3Uq@pRlkQ)bIdZ>pIrJ-wi{+Q~Bco!hKl>#~~AyspSp za<_yXM`P>E(v+&wve%Xk5G)2~d`jn7wX0U9T=IjPLS@?1y7m>#H5F!2>6{kMuEo&S znM%7McG#BeS{7nU8V3-i&z!2V)kfv`!Etq;p=_n9>Af|XrnUB7Ewvv_=+>H2=e(gU ziyh5(w5iLis#RrdNvh0AkzcE#m{3+&50AXA$S!DE)G)v?HDFz>4BWSQU1-9$P3<%# zrKL-)GT?)6D~s4pf4K0Y8TrZ~a8IV35OBy%hHdLAr3=95*&&X=8GmOqp4u1<$6=SR zF0`t!`-Hq@VNq{#O;L%|%xr=S7f_v zB_?B$sGRw$^*$9>Tx@~=116*cGf*j+1ta%Vhxg=wI`YV+g~Hp~CiM-a*};fugp|ut zIE?UG4p3#68UQ+^G(e~yV3SmSi?GXVN2+jI0g-0K-BSDCEP9;Iths4PV&xrp2?GY!w-E?4n>_AY+waT@W zj!u^;4dyyiwbz3Q4ORdHBXk2CNEW?)BOSRZpH?t5Lm z4667Y9Rr~);YxDd?@gy|D*L^m8cz8CQa)gvKe3{lVm8v|R~F~8+B*3_vCAhqR($yD z?BglBkX4nH4-*X9@S)_%;e03ekRAElnram;plit&j{5AEZl0x`dSp z&l?{uXiB-B*GtR>%Tch(%Nxhx_8lCZ(J=IwA^k!_77h6#DlQ)N2NVDmW1vSqOgmFE+I3^ zlYJ~eZlCfidxw4oz?|Cdm-M*#bbiL@IX6#32G36eKcrPx;F6!_n6e<-Og^$3)-LYg z%rpL?AZXCj%?i>4_*Xl8D}5h3B|&vid&7!_rV&^9u8ZR6uEeZ0K#ESwIHu{sYYZ~w zk#tSbZ3QM z=%#yhH2&rKpa1atb<-v4=Zhji(--P8q1HU}0rVM&yw#4|JO(3{BNm^Mvf4qjJ1|JH z7E%5YP3-(1fp^kLOgp1V!ZZdhbY-M1_}}XVXHpy=RtZ1Jl!egtB3a`WCtt%iq8Y;^YXg(pwZlMZ zRCcIhUh~M-9#Ihi;9yn&jMKxPYlAMvL!O*v4xR@v2iFdVhm_v2L^3apLz~n03WW?z zxEURgJUO!fATA@3MIaX^$)hl(wd|5vJDTC$&}~^MGE4+~!pTGVDQHby<_f(Zqj@~& z#AeT!$$Cy4aUCe>+-pFEoHw4P*h2yg7KVQq22Qu6{u~^@Tm~4V zxi-U1cx+(`Ffd8BBRc+$w)97K9lMO^v8e=lWl{W}(33tjhoC1%AM*9Qb5x^Gu``K@ zPbb{mTaq!&F|GNtmhR9DF0Y0o6YhTde1SW$hcbB>XxvT z`oNlR+ch1}fJVAfp7*<6tg>L9n7?M zpYR3;FPr#?-8Sh@I?bkP(7#6^Nb%}BHm=ckY&yg|BwoN5`QL4&-ClkBTlX`vQH zX+a+CbHJH|tL(Ulc4dl!^ct@_TG9#8?u|EdQnF8;4nzxIr z=`7#v!Qg-K8|xcj8>;XH#;*Ad-R=jKhzQHz4{`)b?$sx>qkY2)gHe=*Ca;OKs)Ih_ z-H;60JaIKJhdn5u%qecQEOt9)BZ6Q}*`LEjhQnsk9sfTc!g=lYU0|=8!kFR36Mn?k p8vA1E;C!%mN6fqT07K}#y)Jg0M$E^--ys?%ym&gCbe2d5{x4~wt+fCE literal 0 HcmV?d00001 diff --git a/frontend/src/pages/my-words/index.tsx b/frontend/src/pages/my-words/index.tsx new file mode 100644 index 00000000..c9b6c2fc --- /dev/null +++ b/frontend/src/pages/my-words/index.tsx @@ -0,0 +1,319 @@ +import { + generateText, + jsonSchema, + type ModelMessage, + stepCountIs, + tool, +} from 'ai'; +import { useCallback, useContext, useEffect, useRef, useState } from 'react'; +import { AiOutlineSend } from 'react-icons/ai'; + +import { OPENAI_MODEL, openai } from '@/api/openai'; +import { EditorContext } from '@/contexts/editorContext'; +import { buildCorpus, validateText } from './corpus'; +import classes from './styles.module.css'; + +const SYSTEM_PROMPT = `You are a writing collaborator working under one strict rule: you may edit the writer's document, but you may NEVER introduce your own words or phrases. + +Every word you place in the document must come from the writer's own corpus — the document, their scratchpad, and their messages to you — joined only by punctuation and a small closed set of glue words (a, an, the, and, or, of, to, in, on, ...). The harness enforces this: any str_replace or insert whose text is not lifted from the corpus is REJECTED and returned to you with an explanation. + +How to work: +- Use \`view\` to read the current document before editing. +- Use \`str_replace\` and \`insert\` to weave the writer's existing phrases into clearer prose. Reuse their exact wording; only add punctuation and glue words. +- When you need words you don't have, do NOT invent them. Ask the writer a short question, or use \`highlight\` to point at the passage you're asking about. +- Take short turns. Your spoken replies must be one or two sentences — the writer sees them as fleeting captions, not a chat log. + +Prefer asking the writer for their words over guessing. Never pad your replies.`; + +/** A turn's worth of lightweight signals about what the writer just did. */ +function buildActivityNote(opts: { + scratchpad: string; + scratchpadChanged: boolean; + selectedText: string; +}): string | null { + const parts: string[] = []; + if (opts.scratchpadChanged && opts.scratchpad.trim().length > 0) { + parts.push(`The writer's scratchpad now reads:\n"""\n${opts.scratchpad}\n"""`); + } + if (opts.selectedText.trim().length > 0) { + parts.push(`The writer has selected this passage: "${opts.selectedText}"`); + } + return parts.length > 0 ? parts.join('\n\n') : null; +} + +export default function MyWords() { + const editorAPI = useContext(EditorContext); + + // The writer's own material. + const [scratchpad, setScratchpad] = useState(''); + const [sentMessages, setSentMessages] = useState([]); + + // Ephemeral AI caption — replaced each turn, never kept as scrollback. + const [aiUtterance, setAiUtterance] = useState( + "Tell me what you're trying to say, and I'll help you shape it in your own words.", + ); + const [isThinking, setIsThinking] = useState(false); + const [input, setInput] = useState(''); + + // Refs read by the tool loop / activity tracking (always latest values). + const scratchpadRef = useRef(scratchpad); + scratchpadRef.current = scratchpad; + const sentMessagesRef = useRef(sentMessages); + sentMessagesRef.current = sentMessages; + + // Running model transcript (assistant turns + tool calls/results). + const modelMessagesRef = useRef([]); + // What we last told the model, so signals stay lightweight (only deltas). + const lastSentScratchpadRef = useRef(''); + const selectedTextRef = useRef(''); + + // Track the document selection so we can surface it as an activity signal. + useEffect(() => { + const handler = () => { + void editorAPI + .getDocContext() + .then((ctx) => { + selectedTextRef.current = ctx.selectedText ?? ''; + }) + .catch(() => {}); + }; + editorAPI.addSelectionChangeHandler(handler); + return () => editorAPI.removeSelectionChangeHandler(handler); + }, [editorAPI]); + + const runTurn = useCallback(async () => { + setIsThinking(true); + + // Snapshot the writer's material for this turn. The document is read + // fresh inside each tool call, so edits the AI makes stay consistent. + const scratchpadNow = scratchpadRef.current; + const messagesNow = sentMessagesRef.current; + + const makeCorpus = async () => + buildCorpus({ + docText: await editorAPI.getDocText(), + scratchpad: scratchpadNow, + userMessages: messagesNow, + }); + + const tools = { + view: tool({ + description: + 'Read the current full text of the document being edited.', + inputSchema: jsonSchema>({ + type: 'object', + properties: {}, + additionalProperties: false, + }), + execute: async () => { + const text = await editorAPI.getDocText(); + return text.trim().length > 0 ? text : '(the document is empty)'; + }, + }), + str_replace: tool({ + description: + "Replace the first occurrence of old_str with new_str. new_str must be lifted from the writer's corpus (plus glue words/punctuation).", + inputSchema: jsonSchema<{ old_str: string; new_str: string }>({ + type: 'object', + properties: { + old_str: { + type: 'string', + description: 'Exact existing text to replace.', + }, + new_str: { + type: 'string', + description: + "Replacement text, drawn from the writer's words.", + }, + }, + required: ['old_str', 'new_str'], + additionalProperties: false, + }), + execute: async ({ old_str, new_str }) => { + const check = validateText(new_str, await makeCorpus()); + if (!check.ok) { + return `REJECTED: "${check.offending}" is not in the writer's words. Use only their phrases (plus glue words/punctuation), or ask them for the words you need.`; + } + try { + await editorAPI.applyEdit({ + type: 'str_replace', + oldStr: old_str, + newStr: new_str, + }); + return 'Applied.'; + } catch (e) { + return `Could not apply: ${(e as Error).message}`; + } + }, + }), + insert: tool({ + description: + "Insert text. If `after` is given, insert it right after that existing text; otherwise insert at the cursor. The text must be lifted from the writer's corpus (plus glue words/punctuation).", + inputSchema: jsonSchema<{ after?: string; text: string }>({ + type: 'object', + properties: { + after: { + type: 'string', + description: + 'Existing text to insert after (optional).', + }, + text: { + type: 'string', + description: + "Text to insert, drawn from the writer's words.", + }, + }, + required: ['text'], + additionalProperties: false, + }), + execute: async ({ after, text }) => { + const check = validateText(text, await makeCorpus()); + if (!check.ok) { + return `REJECTED: "${check.offending}" is not in the writer's words. Use only their phrases (plus glue words/punctuation), or ask them for the words you need.`; + } + try { + await editorAPI.applyEdit({ type: 'insert', after, text }); + return 'Applied.'; + } catch (e) { + return `Could not apply: ${(e as Error).message}`; + } + }, + }), + highlight: tool({ + description: + 'Select a passage in the document to point at it while asking the writer about it.', + inputSchema: jsonSchema<{ phrase: string }>({ + type: 'object', + properties: { + phrase: { + type: 'string', + description: 'Existing text to highlight.', + }, + }, + required: ['phrase'], + additionalProperties: false, + }), + execute: async ({ phrase }) => { + try { + await editorAPI.selectPhrase(phrase); + return 'Highlighted.'; + } catch { + return `Could not find "${phrase}" in the document.`; + } + }, + }), + }; + + try { + const result = await generateText({ + model: openai.chat(OPENAI_MODEL), + system: SYSTEM_PROMPT, + messages: modelMessagesRef.current, + tools, + stopWhen: stepCountIs(8), + }); + modelMessagesRef.current = [ + ...modelMessagesRef.current, + ...result.response.messages, + ]; + setAiUtterance(result.text.trim() || 'Done — take a look.'); + } catch (e) { + setAiUtterance(`⚠️ ${(e as Error).message}`); + } finally { + setIsThinking(false); + } + }, [editorAPI]); + + const send = useCallback(async () => { + const text = input.trim(); + if (!text || isThinking) return; + + // The writer's message becomes part of their corpus and the transcript. + setSentMessages((prev) => [...prev, text]); + setInput(''); + + const note = buildActivityNote({ + scratchpad: scratchpadRef.current, + scratchpadChanged: + scratchpadRef.current !== lastSentScratchpadRef.current, + selectedText: selectedTextRef.current, + }); + lastSentScratchpadRef.current = scratchpadRef.current; + + const content = note ? `${note}\n\n---\n\n${text}` : text; + modelMessagesRef.current = [ + ...modelMessagesRef.current, + { role: 'user', content }, + ]; + + await runTurn(); + }, [input, isThinking, runTurn]); + + return ( +
    +
    + {isThinking ? ( + + + + + + ) : ( + aiUtterance + )} +
    + + +