From aad970122e03ad4b15c02eb0b01932bb2e93f470 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Fri, 12 Jun 2026 12:34:26 -0400 Subject: [PATCH 01/12] feat(framework-editor): show requirement context in edit dialog + highlight row When editing a requirement description in the large multi-line editor, the dialog said only "Edit Requirement Description" with no indication of which requirement was being edited, and the underlying row wasn't highlighted. - Append the requirement's identifier and name to the dialog title, e.g. "Edit Requirement Description - SC-13 - Cryptographic Protection". - EditableCell now reports open/close via onExpandedChange so the page can highlight the row whose editor is open (visible through the 50% overlay). Closes FRAME-7 Co-Authored-By: Claude Opus 4.8 --- ...RequirementsClientPage.expandable.test.tsx | 6 ++- .../FrameworkRequirementsClientPage.tsx | 40 ++++++++++++++----- .../components/table/EditableCell.test.tsx | 19 +++++++++ .../app/components/table/EditableCell.tsx | 20 ++++++++-- 4 files changed, 69 insertions(+), 16 deletions(-) diff --git a/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/FrameworkRequirementsClientPage.expandable.test.tsx b/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/FrameworkRequirementsClientPage.expandable.test.tsx index 41a0d4182c..51836f7cf1 100644 --- a/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/FrameworkRequirementsClientPage.expandable.test.tsx +++ b/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/FrameworkRequirementsClientPage.expandable.test.tsx @@ -71,7 +71,11 @@ describe('FrameworkRequirementsClientPage — Description column', () => { const description = editableCellProps.find((p) => p.columnId === 'description'); expect(description?.expandable).toBe(true); - expect(description?.expandTitle).toBe('Edit Requirement Description'); + // Identifier + name are appended so the editor dialog says which requirement + // is being edited (FRAME-7), e.g. "… - AC-2 - Account Management". + expect(description?.expandTitle).toBe( + 'Edit Requirement Description - AC-2 - Account Management', + ); // The short single-line columns stay as plain inline edits. for (const columnId of ['identifier', 'name']) { diff --git a/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/FrameworkRequirementsClientPage.tsx b/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/FrameworkRequirementsClientPage.tsx index f6e9570a30..da517c096c 100644 --- a/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/FrameworkRequirementsClientPage.tsx +++ b/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/FrameworkRequirementsClientPage.tsx @@ -73,6 +73,9 @@ export function FrameworkRequirementsClientPage({ const router = useRouter(); const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + // Row whose large description editor is currently open — highlighted so the + // edited row is obvious behind the (semi-transparent) editor dialog. + const [expandedRowId, setExpandedRowId] = useState(null); const initialGridData: RequirementGridRow[] = useMemo( () => @@ -155,16 +158,27 @@ export function FrameworkRequirementsClientPage({ header: 'Description', size: 300, maxSize: 300, - cell: ({ row, getValue }) => ( - - ), + cell: ({ row, getValue }) => { + const { identifier, name } = row.original; + const titleSuffix = [identifier, name].filter(Boolean).join(' - '); + return ( + + setExpandedRowId(open ? row.original.id : null) + } + /> + ); + }, }), columnHelper.accessor('controlTemplates', { header: 'Linked Controls', @@ -369,7 +383,11 @@ export function FrameworkRequirementsClientPage({ {table.getRowModel().rows.map((row) => ( {row.getVisibleCells().map((cell) => ( { setup({ expandable: true, disabled: true }); expect(screen.queryByRole('button', { name: /large editor/i })).toBeNull(); }); + + it('notifies onExpandedChange when the editor opens and on Save', () => { + const onExpandedChange = vi.fn(); + setup({ expandable: true, onExpandedChange }); + fireEvent.click(screen.getByRole('button', { name: /large editor/i })); + expect(onExpandedChange).toHaveBeenLastCalledWith(true); + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'changed' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + expect(onExpandedChange).toHaveBeenLastCalledWith(false); + }); + + it('notifies onExpandedChange(false) on Cancel', () => { + const onExpandedChange = vi.fn(); + setup({ expandable: true, onExpandedChange }); + fireEvent.contextMenu(screen.getByText(/assign account managers/i)); + expect(onExpandedChange).toHaveBeenLastCalledWith(true); + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + expect(onExpandedChange).toHaveBeenLastCalledWith(false); + }); }); diff --git a/apps/framework-editor/app/components/table/EditableCell.tsx b/apps/framework-editor/app/components/table/EditableCell.tsx index 54e1298732..26464cdadc 100644 --- a/apps/framework-editor/app/components/table/EditableCell.tsx +++ b/apps/framework-editor/app/components/table/EditableCell.tsx @@ -24,6 +24,9 @@ interface EditableCellProps { // values like control descriptions. expandable?: boolean; expandTitle?: string; + // Notified when the large editor opens/closes so the parent can highlight + // the row currently being edited. + onExpandedChange?: (open: boolean) => void; } export function EditableCell({ @@ -35,12 +38,21 @@ export function EditableCell({ placeholder = 'Click to edit', expandable = false, expandTitle = 'Edit', + onExpandedChange, }: EditableCellProps) { const [isEditing, setIsEditing] = useState(false); const [editValue, setEditValue] = useState(value ?? ''); const [isExpanded, setIsExpanded] = useState(false); const [expandValue, setExpandValue] = useState(value ?? ''); + // Keep local open state and the parent notification in lockstep so the row + // highlight tracks the dialog exactly (open icon, right-click, save, cancel, + // Esc, and overlay click all route through here). + const setExpanded = (open: boolean) => { + setIsExpanded(open); + onExpandedChange?.(open); + }; + const handleBlur = () => { setIsEditing(false); if (editValue !== (value ?? '')) { @@ -66,14 +78,14 @@ export function EditableCell({ const handleOpenExpanded = () => { if (disabled) return; setExpandValue(value ?? ''); - setIsExpanded(true); + setExpanded(true); }; const handleExpandSave = () => { if (expandValue !== (value ?? '')) { onUpdate(rowId, columnId, expandValue); } - setIsExpanded(false); + setExpanded(false); }; if (disabled) { @@ -136,7 +148,7 @@ export function EditableCell({ - + {expandTitle} @@ -148,7 +160,7 @@ export function EditableCell({ className="min-h-[260px] font-mono text-sm" /> - + ), +})); + +import { ThemeToggle } from './theme-toggle'; + +describe('ThemeToggle', () => { + beforeEach(() => { + vi.clearAllMocks(); + useThemeMock.mockReturnValue({ resolvedTheme: 'light', setTheme }); + }); + + it('switches to dark when currently light', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: /toggle theme/i })); + expect(setTheme).toHaveBeenCalledWith('dark'); + }); + + it('switches to light when currently dark', () => { + useThemeMock.mockReturnValue({ resolvedTheme: 'dark', setTheme }); + render(); + fireEvent.click(screen.getByRole('button', { name: /toggle theme/i })); + expect(setTheme).toHaveBeenCalledWith('light'); + }); +}); diff --git a/apps/framework-editor/app/components/theme-toggle.tsx b/apps/framework-editor/app/components/theme-toggle.tsx new file mode 100644 index 0000000000..777bb1deac --- /dev/null +++ b/apps/framework-editor/app/components/theme-toggle.tsx @@ -0,0 +1,30 @@ +'use client'; + +import { Button } from '@trycompai/ui'; +import { Moon, Sun } from 'lucide-react'; +import { useTheme } from 'next-themes'; +import { useEffect, useState } from 'react'; + +export function ThemeToggle() { + const { resolvedTheme, setTheme } = useTheme(); + const [mounted, setMounted] = useState(false); + + // next-themes can't know the resolved theme during SSR; wait for mount before + // showing a theme-specific icon to avoid a hydration mismatch. + useEffect(() => setMounted(true), []); + + const isDark = mounted && resolvedTheme === 'dark'; + + return ( + + ); +} diff --git a/apps/framework-editor/app/layout.tsx b/apps/framework-editor/app/layout.tsx index 3d5672c63f..46ee0df931 100644 --- a/apps/framework-editor/app/layout.tsx +++ b/apps/framework-editor/app/layout.tsx @@ -6,6 +6,7 @@ import { Toaster as SonnerToaster } from 'sonner'; import { headers } from 'next/headers'; import '../styles/globals.css'; import { Header } from './components/HeaderFrameworks'; +import { ThemeProvider } from './components/theme-provider'; import { auth } from './lib/auth'; import { isInternalUser } from './lib/utils'; @@ -25,14 +26,21 @@ export default async function RootLayout({ children }: { children: ReactNode }) isInternalUser(session.user.email); return ( - + - {hasSession &&
} -
- {children} - - -
+ + {hasSession &&
} +
+ {children} + + +
+ ); diff --git a/apps/framework-editor/package.json b/apps/framework-editor/package.json index 9d1736d060..46c6a7754e 100644 --- a/apps/framework-editor/package.json +++ b/apps/framework-editor/package.json @@ -17,6 +17,7 @@ "framer-motion": "^12.23.9", "lucide-react": "^1.7.0", "next": "^16.2.0", + "next-themes": "^0.4.4", "nuqs": "^2.4.3", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/apps/framework-editor/styles/globals.css b/apps/framework-editor/styles/globals.css index 5cd105322f..77f5f2118a 100644 --- a/apps/framework-editor/styles/globals.css +++ b/apps/framework-editor/styles/globals.css @@ -1,6 +1,36 @@ @import '@trycompai/ui/globals.css'; @config '../tailwind.config.ts'; +/* + * Real dark-mode palette (FRAME-5). @trycompai/ui ships a `.dark` block whose + * values are identical to light, so without this, toggling the `dark` class + * changes nothing. Declared after the import (and unlayered) so it wins the + * cascade over the library defaults. + */ +.dark { + --background: 0 0% 7%; + --foreground: 0 0% 95%; + --card: 0 0% 9%; + --card-foreground: 0 0% 95%; + --popover: 0 0% 9%; + --popover-foreground: 0 0% 95%; + --primary: 165 70% 42%; + --primary-foreground: 0 0% 100%; + --secondary: 0 0% 15%; + --secondary-foreground: 0 0% 95%; + --muted: 0 0% 15%; + --muted-foreground: 0 0% 64%; + --accent: 0 0% 18%; + --accent-foreground: 0 0% 98%; + --destructive: 0 72% 51%; + --destructive-foreground: 0 0% 98%; + --warning: 45 93% 47%; + --warning-foreground: 26 83% 14%; + --border: 0 0% 18%; + --input: 0 0% 18%; + --ring: 165 70% 42%; +} + @layer base { * { @apply border-border; diff --git a/bun.lock b/bun.lock index 3d06bfdff4..f70de19c1f 100644 --- a/bun.lock +++ b/bun.lock @@ -417,6 +417,7 @@ "framer-motion": "^12.23.9", "lucide-react": "^1.7.0", "next": "^16.2.0", + "next-themes": "^0.4.4", "nuqs": "^2.4.3", "react": "^19.0.0", "react-dom": "^19.0.0", From 065395056e8608eb6989f57cd123178b2dfa7655 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Fri, 12 Jun 2026 13:00:44 -0400 Subject: [PATCH 05/12] fix(analytics): fall back to server-evaluated feature flags when posthog-js is blocked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Timeline tab (is-timeline-enabled) is gated by useFeatureFlag, which relied solely on the browser's posthog-js /flags request. Ad blockers, privacy browsers, and corporate proxies block that request (the /ingest proxy path is on public blocklists), so the flag never resolved and the tab never rendered for affected customers — while impersonating staff on unblocked machines saw it fine. The org layout already evaluates all flags server-side (posthog-node with the organization group) for nav gating. Provide that map to the client via a new ServerFeatureFlagsProvider and let useFeatureFlag treat the flag as enabled when either the live client value or the server-evaluated value says so. No changes needed at the flag call sites. Co-Authored-By: Claude Fable 5 --- apps/app/src/app/(app)/[orgId]/layout.tsx | 70 ++++----- .../overview/components/OverviewTabs.test.tsx | 142 ++++++++++++++++++ .../server-feature-flags-provider.tsx | 31 ++++ .../analytics/src/hooks/use-feature-flag.ts | 25 ++- packages/analytics/src/index.ts | 1 + 5 files changed, 230 insertions(+), 39 deletions(-) create mode 100644 apps/app/src/app/(app)/[orgId]/overview/components/OverviewTabs.test.tsx create mode 100644 packages/analytics/src/components/server-feature-flags-provider.tsx diff --git a/apps/app/src/app/(app)/[orgId]/layout.tsx b/apps/app/src/app/(app)/[orgId]/layout.tsx index d85c2e2314..b284e75bea 100644 --- a/apps/app/src/app/(app)/[orgId]/layout.tsx +++ b/apps/app/src/app/(app)/[orgId]/layout.tsx @@ -15,7 +15,7 @@ import type { OrganizationFromMe } from '@/types'; import { auth } from '@/utils/auth'; import { GetObjectCommand } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@/lib/s3-presigner'; -import { OrganizationIdentifier } from '@trycompai/analytics'; +import { OrganizationIdentifier, ServerFeatureFlagsProvider } from '@trycompai/analytics'; import { db, Role } from '@db/server'; import dynamic from 'next/dynamic'; import { cookies, headers } from 'next/headers'; @@ -146,21 +146,21 @@ export default async function Layout({ // Check feature flags for menu items. Security (penetration tests) is // always enabled now — the nav rail entry is gated solely by the // `pentest:read` permission downstream, matching `security/layout.tsx`. - let isQuestionnaireEnabled = false; - let isTrustNdaEnabled = false; - let isWebAutomationsEnabled = false; + // The full map is also provided to the client via ServerFeatureFlagsProvider + // so `useFeatureFlag` keeps working when posthog-js is blocked client-side. + const featureFlags = session?.user?.id + ? await getFeatureFlags(session.user.id, { + groups: { organization: organization.id }, + }) + : {}; + const isQuestionnaireEnabled = featureFlags['ai-vendor-questionnaire'] === true; + const isTrustNdaEnabled = + featureFlags['is-trust-nda-enabled'] === true || + featureFlags['is-trust-nda-enabled'] === 'true'; + const isWebAutomationsEnabled = + featureFlags['is-web-automations-enabled'] === true || + featureFlags['is-web-automations-enabled'] === 'true'; const isSecurityEnabled = true; - if (session?.user?.id) { - const flags = await getFeatureFlags(session.user.id, { - groups: { organization: organization.id }, - }); - isQuestionnaireEnabled = flags['ai-vendor-questionnaire'] === true; - isTrustNdaEnabled = - flags['is-trust-nda-enabled'] === true || flags['is-trust-nda-enabled'] === 'true'; - isWebAutomationsEnabled = - flags['is-web-automations-enabled'] === true || - flags['is-web-automations-enabled'] === 'true'; - } // Check auditor role const hasAuditorRole = roles.includes(Role.auditor); @@ -192,25 +192,27 @@ export default async function Layout({ initialToken={publicAccessToken || undefined} > - - {children} - + + + {children} + + ); diff --git a/apps/app/src/app/(app)/[orgId]/overview/components/OverviewTabs.test.tsx b/apps/app/src/app/(app)/[orgId]/overview/components/OverviewTabs.test.tsx new file mode 100644 index 0000000000..0114a7739d --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/overview/components/OverviewTabs.test.tsx @@ -0,0 +1,142 @@ +import { render, renderHook, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +// The live posthog-js value is controlled per-test. `undefined` simulates a +// client whose /ingest/flags request is blocked (ad blocker, privacy browser, +// corporate proxy) — flags never load, so the hook never resolves. +const { useFeatureFlagEnabledMock } = vi.hoisted(() => ({ + useFeatureFlagEnabledMock: vi.fn<(flag: string) => boolean | undefined>(), +})); + +vi.mock('posthog-js/react', () => ({ + useFeatureFlagEnabled: (flag: string) => useFeatureFlagEnabledMock(flag), + usePostHog: () => null, + PostHogProvider: ({ children }: { children: React.ReactNode }) => children, +})); + +vi.mock('next/navigation', () => ({ + useParams: () => ({ orgId: 'org_test123' }), + usePathname: () => '/org_test123/overview', +})); + +vi.mock('@/hooks/use-findings-api', () => ({ + useOrganizationFindings: () => ({ data: undefined }), +})); + +vi.mock('@db', () => ({ + FindingStatus: { open: 'open' }, +})); + +import { ServerFeatureFlagsProvider, useFeatureFlag } from '@trycompai/analytics'; +import { OverviewTabs } from './OverviewTabs'; + +describe('useFeatureFlag server fallback', () => { + it('returns false when flags never load and no server flags are provided', () => { + useFeatureFlagEnabledMock.mockReturnValue(undefined); + + const { result } = renderHook(() => useFeatureFlag('is-timeline-enabled')); + + expect(result.current).toBe(false); + }); + + it('falls back to the server-evaluated value when flags never load', () => { + useFeatureFlagEnabledMock.mockReturnValue(undefined); + + const { result } = renderHook(() => useFeatureFlag('is-timeline-enabled'), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + expect(result.current).toBe(true); + }); + + it('treats multivariate (string) server values as enabled', () => { + useFeatureFlagEnabledMock.mockReturnValue(undefined); + + const { result } = renderHook(() => useFeatureFlag('is-timeline-enabled'), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + expect(result.current).toBe(true); + }); + + it('stays false when both live and server values are disabled', () => { + useFeatureFlagEnabledMock.mockReturnValue(false); + + const { result } = renderHook(() => useFeatureFlag('is-timeline-enabled'), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + expect(result.current).toBe(false); + }); + + it('prefers an enabled server value over a stale persisted live=false', () => { + // posthog-js serves flags persisted from an older session even when the + // network is blocked — those can predate the admin toggle. The fresher + // server-side evaluation must win for enable rollouts. + useFeatureFlagEnabledMock.mockReturnValue(false); + + const { result } = renderHook(() => useFeatureFlag('is-timeline-enabled'), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + expect(result.current).toBe(true); + }); + + it('returns true from the live value alone, without a provider', () => { + useFeatureFlagEnabledMock.mockReturnValue(true); + + const { result } = renderHook(() => useFeatureFlag('is-timeline-enabled')); + + expect(result.current).toBe(true); + }); +}); + +describe('OverviewTabs timeline gating', () => { + it('shows the Timeline tab via server flags when the client cannot load flags', () => { + useFeatureFlagEnabledMock.mockReturnValue(undefined); + + render( + + + , + ); + + expect(screen.getByText('Timeline')).toBeInTheDocument(); + }); + + it('hides the Timeline tab when the flag is off everywhere', () => { + useFeatureFlagEnabledMock.mockReturnValue(undefined); + + render( + + + , + ); + + expect(screen.queryByText('Timeline')).not.toBeInTheDocument(); + }); + + it('shows the Timeline tab from the live client flag without server flags', () => { + useFeatureFlagEnabledMock.mockReturnValue(true); + + render(); + + expect(screen.getByText('Timeline')).toBeInTheDocument(); + }); +}); diff --git a/packages/analytics/src/components/server-feature-flags-provider.tsx b/packages/analytics/src/components/server-feature-flags-provider.tsx new file mode 100644 index 0000000000..f203628ce9 --- /dev/null +++ b/packages/analytics/src/components/server-feature-flags-provider.tsx @@ -0,0 +1,31 @@ +'use client'; + +import { createContext, useContext } from 'react'; + +export type ServerFeatureFlags = Record; + +const ServerFeatureFlagsContext = createContext(null); + +/** + * Provides feature flags evaluated server-side (posthog-node) as a fallback + * for `useFeatureFlag`. The client-side posthog-js flags request is routinely + * blocked by ad blockers / privacy browsers / corporate proxies — without this + * fallback, flag-gated UI silently never renders for those users. + */ +export function ServerFeatureFlagsProvider({ + flags, + children, +}: { + flags: ServerFeatureFlags; + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} + +export function useServerFeatureFlags(): ServerFeatureFlags | null { + return useContext(ServerFeatureFlagsContext); +} diff --git a/packages/analytics/src/hooks/use-feature-flag.ts b/packages/analytics/src/hooks/use-feature-flag.ts index 6f6e29e714..2f8fe7a6c1 100644 --- a/packages/analytics/src/hooks/use-feature-flag.ts +++ b/packages/analytics/src/hooks/use-feature-flag.ts @@ -1,14 +1,29 @@ 'use client'; import { useFeatureFlagEnabled } from 'posthog-js/react'; +import { useServerFeatureFlags } from '../components/server-feature-flags-provider'; /** * Returns whether a feature flag is enabled for the current user/group. - * Thin wrapper around posthog-js's reactive hook. Returns false until flags - * finish loading — callers should treat the flag as the source of truth, and - * create + toggle the flag via the admin UI (or PostHog) to enable features - * locally. + * + * The flag is on when EITHER source says so: + * - the live posthog-js value (fresh while the browser can reach PostHog), or + * - flags evaluated server-side and passed down via ServerFeatureFlagsProvider. + * + * The OR matters: when the client's flags request is blocked (ad blockers, + * privacy browsers, corporate proxies) the live value is `undefined` forever — + * or worse, a stale `false` persisted from an old session — and only the + * server-evaluated value can turn the feature on. Mirrors PostHog "enabled" + * semantics: `true` or any non-empty variant string counts as enabled. */ export function useFeatureFlag(flagKey: string): boolean { - return useFeatureFlagEnabled(flagKey) === true; + const liveValue = useFeatureFlagEnabled(flagKey); + const serverFlags = useServerFeatureFlags(); + const serverValue = serverFlags?.[flagKey]; + + return ( + liveValue === true || + serverValue === true || + (typeof serverValue === 'string' && serverValue.length > 0) + ); } diff --git a/packages/analytics/src/index.ts b/packages/analytics/src/index.ts index 45754873df..cc0ab9862d 100644 --- a/packages/analytics/src/index.ts +++ b/packages/analytics/src/index.ts @@ -27,4 +27,5 @@ export const Analytics = { export * from './components/page-view'; export * from './components/provider'; export * from './components/organization-identifier'; +export * from './components/server-feature-flags-provider'; export * from './hooks/use-feature-flag'; From 9ef22dc5efc60aca3d9a09bc7659b1f93503fd20 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Fri, 12 Jun 2026 12:42:40 -0400 Subject: [PATCH 06/12] feat(framework-editor): resizable + size-remembering description editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The large multi-line cell editor could effectively only grow vertically (width was pinned by w-full inside a 760px dialog), and it always reopened at the default size — painful for long requirement text (e.g. NIST PL-2). - Textarea is now resizable in both directions (resize, min 320px wide), and the dialog grows to fit it up to 95vw. - The chosen size is remembered in localStorage and restored on reopen. Size persistence is a small tested helper (load/save with validation). Closes FRAME-3 Co-Authored-By: Claude Opus 4.8 --- .../components/table/EditableCell.test.tsx | 15 ++++- .../app/components/table/EditableCell.tsx | 32 +++++++++- .../table/editor-size-storage.test.ts | 35 +++++++++++ .../components/table/editor-size-storage.ts | 60 +++++++++++++++++++ 4 files changed, 138 insertions(+), 4 deletions(-) create mode 100644 apps/framework-editor/app/components/table/editor-size-storage.test.ts create mode 100644 apps/framework-editor/app/components/table/editor-size-storage.ts diff --git a/apps/framework-editor/app/components/table/EditableCell.test.tsx b/apps/framework-editor/app/components/table/EditableCell.test.tsx index cb527f94cf..e04f032b7e 100644 --- a/apps/framework-editor/app/components/table/EditableCell.test.tsx +++ b/apps/framework-editor/app/components/table/EditableCell.test.tsx @@ -1,6 +1,7 @@ import { fireEvent, render, screen } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { EditableCell } from './EditableCell'; +import { clearEditorSize, saveEditorSize } from './editor-size-storage'; // The ui package ships untranspiled JSX in dist; stub the bits the cell uses. vi.mock('@trycompai/ui', () => ({ @@ -55,7 +56,10 @@ describe('EditableCell — non-expandable (default)', () => { }); describe('EditableCell — expandable', () => { - beforeEach(() => vi.clearAllMocks()); + beforeEach(() => { + vi.clearAllMocks(); + clearEditorSize(); + }); it('shows an expand affordance', () => { setup({ expandable: true }); @@ -139,4 +143,13 @@ describe('EditableCell — expandable', () => { fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); expect(onExpandedChange).toHaveBeenLastCalledWith(false); }); + + it('reopens the editor at the persisted size (FRAME-3)', () => { + saveEditorSize({ width: 900, height: 500 }); + setup({ expandable: true }); + fireEvent.click(screen.getByRole('button', { name: /large editor/i })); + const textarea = screen.getByRole('textbox') as HTMLTextAreaElement; + expect(textarea.style.width).toBe('900px'); + expect(textarea.style.height).toBe('500px'); + }); }); diff --git a/apps/framework-editor/app/components/table/EditableCell.tsx b/apps/framework-editor/app/components/table/EditableCell.tsx index 26464cdadc..02204891fe 100644 --- a/apps/framework-editor/app/components/table/EditableCell.tsx +++ b/apps/framework-editor/app/components/table/EditableCell.tsx @@ -10,7 +10,8 @@ import { Textarea, } from '@trycompai/ui'; import { Maximize2 } from 'lucide-react'; -import { useState } from 'react'; +import { useRef, useState } from 'react'; +import { loadEditorSize, saveEditorSize, type EditorSize } from './editor-size-storage'; interface EditableCellProps { value: string | null; @@ -44,6 +45,10 @@ export function EditableCell({ const [editValue, setEditValue] = useState(value ?? ''); const [isExpanded, setIsExpanded] = useState(false); const [expandValue, setExpandValue] = useState(value ?? ''); + // Remembered editor size (FRAME-3): the large editor is resizable in both + // directions and reopens at the size the user last left it. + const [editorSize, setEditorSize] = useState(null); + const textareaRef = useRef(null); // Keep local open state and the parent notification in lockstep so the row // highlight tracks the dialog exactly (open icon, right-click, save, cancel, @@ -78,6 +83,7 @@ export function EditableCell({ const handleOpenExpanded = () => { if (disabled) return; setExpandValue(value ?? ''); + setEditorSize(loadEditorSize()); setExpanded(true); }; @@ -88,6 +94,19 @@ export function EditableCell({ setExpanded(false); }; + // Persist the editor size after a resize-handle drag (fires on pointer + // release). Skipped when unchanged so plain clicks don't thrash storage. + const handleEditorResizeEnd = () => { + const el = textareaRef.current; + if (!el) return; + const next: EditorSize = { width: el.offsetWidth, height: el.offsetHeight }; + if (editorSize && next.width === editorSize.width && next.height === editorSize.height) { + return; + } + setEditorSize(next); + saveEditorSize(next); + }; + if (disabled) { return ( @@ -149,15 +168,22 @@ export function EditableCell({ - + {expandTitle}