Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { getEditorMarkdown } from '../story-editor';
import type { MutableRefObject } from 'react';
import type { Editor as TiptapEditor } from '@tiptap/react';
import type { StoryViewMode } from '../story-viewer.types';
import type { StoryCodeViewHandle } from '../story-code-view';
import { trpc } from '@/main';

interface UseStoryViewerVersionActionsParams {
Expand All @@ -13,6 +14,8 @@ interface UseStoryViewerVersionActionsParams {
currentVersionCode?: string;
isViewingLatest: boolean;
tiptapEditorRef: MutableRefObject<TiptapEditor | null>;
codeViewRef: MutableRefObject<StoryCodeViewHandle | null>;
viewMode: StoryViewMode;
setViewMode: (mode: StoryViewMode) => void;
}

Expand All @@ -23,6 +26,8 @@ export const useStoryViewerVersionActions = ({
currentVersionCode,
isViewingLatest,
tiptapEditorRef,
codeViewRef,
viewMode,
setViewMode,
}: UseStoryViewerVersionActionsParams) => {
const queryClient = useQueryClient();
Expand Down Expand Up @@ -58,13 +63,33 @@ export const useStoryViewerVersionActions = ({
);

const handleSave = useCallback(() => {
const editor = tiptapEditorRef.current;
const hasVersionData = storyTitle !== undefined && currentVersionCode !== undefined;
if (!editor || !hasVersionData) {
if (!hasVersionData) {
return;
}

let newCode: string | null = null;
if (viewMode === 'edit') {
const editor = tiptapEditorRef.current;
if (!editor) {
return;
}
newCode = getEditorMarkdown(editor);
} else if (viewMode === 'code') {
const codeView = codeViewRef.current;
if (!codeView) {
return;
}
if (codeView.getErrors().length > 0) {
return;
}
newCode = codeView.getCode();
}

if (newCode === null) {
return;
}

const newCode = getEditorMarkdown(editor);
if (newCode === currentVersionCode) {
setViewMode('preview');
return;
Expand All @@ -79,7 +104,17 @@ export const useStoryViewerVersionActions = ({
});

setViewMode('preview');
}, [chatId, storySlug, storyTitle, currentVersionCode, tiptapEditorRef, createVersionMutation, setViewMode]);
}, [
chatId,
storySlug,
storyTitle,
currentVersionCode,
tiptapEditorRef,
codeViewRef,
viewMode,
createVersionMutation,
setViewMode,
]);

const handleRestore = useCallback(() => {
const hasVersionData = storyTitle !== undefined && currentVersionCode !== undefined;
Expand Down
164 changes: 159 additions & 5 deletions apps/frontend/src/components/side-panel/story-code-view.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { memo } from 'react';
import { Editor } from '@monaco-editor/react';
import { validateStoryCode } from '@nao/shared/story-validation';
import { AlertTriangle } from 'lucide-react';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { StoryValidationError } from '@nao/shared/story-validation';
import type { Monaco } from '@monaco-editor/react';
import type { editor } from 'monaco-editor';
import { useEditorTheme } from '@/hooks/use-editor-theme';

const MONACO_OPTIONS = {
minimap: { enabled: false },
Expand All @@ -9,13 +15,161 @@ const MONACO_OPTIONS = {
scrollBeyondLastLine: false,
padding: { top: 16, bottom: 16 },
wordWrap: 'on' as const,
readOnly: true,
fontSize: 13,
};

export const StoryCodeView = memo(function StoryCodeView({ code }: { code: string }) {
export interface StoryCodeViewHandle {
getCode: () => string;
getErrors: () => StoryValidationError[];
}

interface StoryCodeViewProps {
code: string;
readOnly?: boolean;
codeRef?: React.MutableRefObject<StoryCodeViewHandle | null>;
onDirtyChange?: (dirty: boolean) => void;
onValidChange?: (valid: boolean) => void;
onSave?: () => void;
}

const MARKER_OWNER = 'nao-story-validation';

export const StoryCodeView = memo(function StoryCodeView({
code,
readOnly = false,
codeRef,
onDirtyChange,
onValidChange,
onSave,
}: StoryCodeViewProps) {
const editorTheme = useEditorTheme();
const [draft, setDraft] = useState(code);
const [errors, setErrors] = useState<StoryValidationError[]>(() => (readOnly ? [] : validateStoryCode(code)));
const editorInstanceRef = useRef<editor.IStandaloneCodeEditor | null>(null);
const monacoRef = useRef<Monaco | null>(null);
const onSaveRef = useRef(onSave);
onSaveRef.current = onSave;

useEffect(() => {
setDraft(code);
setErrors(readOnly ? [] : validateStoryCode(code));
}, [code, readOnly]);

useEffect(() => {
onDirtyChange?.(draft !== code);
}, [draft, code, onDirtyChange]);

useEffect(() => {
onValidChange?.(errors.length === 0);
}, [errors, onValidChange]);

useEffect(() => {
if (!codeRef) {
return;
}
codeRef.current = {
getCode: () => draft,
getErrors: () => errors,
};
return () => {
codeRef.current = null;
};
}, [codeRef, draft, errors]);

useEffect(() => {
const monaco = monacoRef.current;
const instance = editorInstanceRef.current;
if (!monaco || !instance) {
return;
}
const model = instance.getModel();
if (!model) {
return;
}
const markers = errors.map((error) => ({
severity: monaco.MarkerSeverity.Error,
message: error.message,
startLineNumber: error.line,
startColumn: error.column,
endLineNumber: error.line,
endColumn: error.column + Math.max(error.length, 1),
}));
monaco.editor.setModelMarkers(model, MARKER_OWNER, markers);
}, [errors]);

const handleMount = useCallback((instance: editor.IStandaloneCodeEditor, monaco: Monaco) => {
editorInstanceRef.current = instance;
monacoRef.current = monaco;

instance.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
onSaveRef.current?.();
});
}, []);

useEffect(() => {
return () => {
const monaco = monacoRef.current;
const instance = editorInstanceRef.current;
if (monaco && instance) {
const model = instance.getModel();
if (model) {
monaco.editor.setModelMarkers(model, MARKER_OWNER, []);
}
}
};
}, []);

const handleChange = useCallback(
(value: string | undefined) => {
const next = value ?? '';
setDraft(next);
setErrors(readOnly ? [] : validateStoryCode(next));
},
[readOnly],
);

const options = useMemo(
() => ({
...MONACO_OPTIONS,
readOnly,
}),
[readOnly],
);

return (
<div className='h-full'>
<Editor value={code} language='markdown' theme='light' options={MONACO_OPTIONS} />
<div className='flex h-full flex-col'>
{!readOnly && errors.length > 0 && <ValidationErrorBanner errors={errors} />}
<div className='flex-1 min-h-0'>
<Editor
value={draft}
language='markdown'
theme={editorTheme}
options={options}
onMount={handleMount}
onChange={handleChange}
/>
</div>
</div>
);
});

function ValidationErrorBanner({ errors }: { errors: StoryValidationError[] }) {
return (
<div className='shrink-0 border-b border-red-200 bg-red-50 px-4 py-2 text-xs text-red-800 dark:border-red-900 dark:bg-red-950/40 dark:text-red-200'>
<div className='flex items-center gap-1.5 font-medium'>
<AlertTriangle className='size-3.5' />
<span>
{errors.length} validation {errors.length === 1 ? 'error' : 'errors'}
</span>
</div>
<ul className='mt-1 flex flex-col gap-0.5'>
{errors.slice(0, 5).map((error, i) => (
<li key={i} className='truncate'>
<span className='font-mono opacity-70'>L{error.line}:</span> {error.message}
</li>
))}
{errors.length > 5 && <li className='opacity-70'>and {errors.length - 5} more...</li>}
</ul>
</div>
);
}
29 changes: 28 additions & 1 deletion apps/frontend/src/components/side-panel/story-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ export interface StoryHeaderProps {
onRefreshData: () => void;
onOpenLiveSettings: () => void;
onClose: () => void;
isCodeDirty?: boolean;
isCodeValid?: boolean;
}

export const StoryHeader = memo(function StoryHeader({
Expand Down Expand Up @@ -87,11 +89,14 @@ export const StoryHeader = memo(function StoryHeader({
onRefreshData,
onOpenLiveSettings,
onClose,
isCodeDirty = false,
isCodeValid = true,
}: StoryHeaderProps) {
const isMobile = useIsMobile();
const otherStories = useMemo(() => allStories.filter((s) => s.id !== storySlug), [allStories, storySlug]);
const hasMultiple = otherStories.length > 0;
const showSubHeader = viewMode === 'edit' || !isViewingLatest;
const isEditingCode = viewMode === 'code' && isCodeDirty && !isReadonlyMode;
const showSubHeader = viewMode === 'edit' || isEditingCode || !isViewingLatest;

const titleElement = hasMultiple ? (
<DropdownMenu>
Expand Down Expand Up @@ -293,6 +298,28 @@ export const StoryHeader = memo(function StoryHeader({
</Button>
</div>
</>
) : isEditingCode ? (
<>
<span className='text-xs text-muted-foreground'>
{isCodeValid ? 'Editing code' : 'Fix validation errors to save'}
</span>
<div className='flex items-center gap-2'>
<Button variant='outline' size='sm' onClick={() => onViewModeChange('preview')}>
Cancel
</Button>
<Button
variant='default'
size='sm'
onClick={onSave}
disabled={!isCodeValid}
className='gap-1.5'
>
<Save className='size-3' />
<span>Save</span>
<kbd className='text-[10px] opacity-60 font-sans'>⌘S</kbd>
</Button>
</div>
</>
) : (
<>
<span className='text-xs text-muted-foreground'>
Expand Down
26 changes: 24 additions & 2 deletions apps/frontend/src/components/side-panel/story-viewer.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback, useRef, useState, useSyncExternalStore } from 'react';
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react';
import { useQuery } from '@tanstack/react-query';
import { ShareStoryDialog } from '../share-dialog.story';
import { StoryEditor } from './story-editor';
Expand All @@ -18,6 +18,7 @@ import { useStoryViewerVersionActions } from './hooks/use-story-viewer-version-a
import { useStoryViewerVersions } from './hooks/use-story-viewer-versions';
import { useStoryViewerViewMode } from './hooks/use-story-viewer-view-mode';
import type { Editor as TiptapEditor } from '@tiptap/react';
import type { StoryCodeViewHandle } from './story-code-view';
import { useSidePanel } from '@/contexts/side-panel';
import { ReadonlyAgentMessagesProvider, useOptionalAgentContext } from '@/contexts/agent.provider';
import { Spinner } from '@/components/ui/spinner';
Expand All @@ -32,6 +33,9 @@ interface StoryViewerProps {

export function StoryViewer({ chatId, storySlug, isReadonlyMode: readonlyProp }: StoryViewerProps) {
const tiptapEditorRef = useRef<TiptapEditor | null>(null);
const codeViewRef = useRef<StoryCodeViewHandle | null>(null);
const [isCodeDirty, setIsCodeDirty] = useState(false);
const [isCodeValid, setIsCodeValid] = useState(true);
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
const { close: closeSidePanel, isReadonlyMode: contextReadonlyMode, shareId } = useSidePanel();
const isReadonlyMode = readonlyProp ?? contextReadonlyMode;
Expand Down Expand Up @@ -83,6 +87,8 @@ export function StoryViewer({ chatId, storySlug, isReadonlyMode: readonlyProp }:
currentVersionCode: currentVersion?.code,
isViewingLatest,
tiptapEditorRef,
codeViewRef,
viewMode,
setViewMode,
});
const { isShareDialogOpen, setIsShareDialogOpen, isShared } = useStoryViewerSharing({
Expand Down Expand Up @@ -113,6 +119,13 @@ export function StoryViewer({ chatId, storySlug, isReadonlyMode: readonlyProp }:
);
const { switchStory } = useStoryViewerSwitchStory({ renderStoryViewer });

useEffect(() => {
if (viewMode !== 'code') {
setIsCodeDirty(false);
setIsCodeValid(true);
}
}, [viewMode]);

useStoryViewerStreamScroll({
scrollContainerRef,
isStreaming: Boolean(draftStory?.isStreaming),
Expand Down Expand Up @@ -164,6 +177,8 @@ export function StoryViewer({ chatId, storySlug, isReadonlyMode: readonlyProp }:
onRefreshData={handleRefreshData}
onOpenLiveSettings={handleOpenLiveSettings}
onClose={closeSidePanel}
isCodeDirty={isCodeDirty}
isCodeValid={isCodeValid}
/>

{Boolean(archivedAt) && <ArchivedBanner chatId={chatId} storySlug={resolvedStorySlug} />}
Expand All @@ -180,7 +195,14 @@ export function StoryViewer({ chatId, storySlug, isReadonlyMode: readonlyProp }:
) : viewMode === 'edit' ? (
<StoryEditor code={storyCode} editorRef={tiptapEditorRef} onSave={handleSave} />
) : (
<StoryCodeView code={storyCode} />
<StoryCodeView
code={storyCode}
readOnly={isReadonlyMode}
codeRef={codeViewRef}
onDirtyChange={setIsCodeDirty}
onValidChange={setIsCodeValid}
onSave={handleSave}
/>
)}
</div>

Expand Down
3 changes: 2 additions & 1 deletion apps/shared/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
"./posthog": "./src/posthog.ts",
"./tools": "./src/tools/index.ts",
"./story-segments": "./src/story-segments.ts",
"./story-table-utils": "./src/story-table-utils.ts"
"./story-table-utils": "./src/story-table-utils.ts",
"./story-validation": "./src/story-validation.ts"
},
"scripts": {
"test": "vitest run",
Expand Down
Loading
Loading