Skip to content
Closed
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
4 changes: 2 additions & 2 deletions app/.env.development
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
VITE_DEFAULT_AGENT_ENDPOINT=http://localhost:9988
VITE_DEFAULT_RUNNER_ENDPOINT=ws://localhost:9988/ws
VITE_DEFAULT_AGENT_ENDPOINT=http://localhost:9977
VITE_DEFAULT_RUNNER_ENDPOINT=ws://localhost:9977/ws
1 change: 1 addition & 0 deletions app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"type": "module",
"scripts": {
"dev": "vite",
"typecheck": "tsc -p tsconfig.app.json --noEmit",
"build": "vite build",
"lint": "eslint .",
"format": "prettier --ignore-path .prettierignore --write .",
Expand Down
15 changes: 10 additions & 5 deletions app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import { Interceptor } from "@connectrpc/connect";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { SidePanelProvider } from "./contexts/SidePanelContext";
import { appState } from "./lib/runtime/AppState";
import GlobalToast from "./components/Toast";

const queryClient = new QueryClient();

Expand Down Expand Up @@ -133,7 +134,12 @@ function App({ branding, initialState = {} }: AppProps) {
<FilesystemStoreProvider>
<ContentsStoreProvider>
<CurrentDocProvider>
<NotebookStoreInitializer />
<NotebookStoreInitializer
agentEndpoint={
initialState?.agentEndpoint ??
import.meta.env.VITE_DEFAULT_AGENT_ENDPOINT
}
/>
<SettingsProvider
requireAuth={initialState?.requireAuth}
agentEndpoint={
Expand All @@ -160,6 +166,7 @@ function App({ branding, initialState = {} }: AppProps) {
<NotebookProvider>
<CellProvider>
<SidePanelProvider>
<GlobalToast />
<AppRouter />
</SidePanelProvider>
</CellProvider>
Expand All @@ -179,7 +186,7 @@ function App({ branding, initialState = {} }: AppProps) {
);
}

function NotebookStoreInitializer() {
function NotebookStoreInitializer({ agentEndpoint }: { agentEndpoint?: string }) {
const { ensureAccessToken } = useGoogleAuth();
const { store, setStore } = useNotebookStore();
const { fsStore, setFsStore } = useFilesystemStore();
Expand Down Expand Up @@ -224,8 +231,6 @@ function NotebookStoreInitializer() {
return;
}

// Use the same agent endpoint for the ContentsService.
const agentEndpoint = import.meta.env.VITE_DEFAULT_AGENT_ENDPOINT;
if (!agentEndpoint) {
return;
}
Expand All @@ -245,7 +250,7 @@ function NotebookStoreInitializer() {
appState.setContentsStore(contentsStoreInstance);
contentsInstanceRef.current = contentsStoreInstance;
setContentsStore(contentsStoreInstance);
}, [contentsStore, setContentsStore]);
}, [agentEndpoint, contentsStore, setContentsStore]);

return null;
}
Expand Down
207 changes: 119 additions & 88 deletions app/src/components/Actions/Actions.tsx

Large diffs are not rendered by default.

13 changes: 8 additions & 5 deletions app/src/components/Actions/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { memo, useCallback, useEffect, useRef, useState } from "react";
import MonacoEditor from "@monaco-editor/react";
import useResizeObserver from "use-resize-observer";

const theme = "vs";
const theme = "vs-dark";

// Editor component for editing code which won't re-render unless the value changes
const Editor = memo(
Expand Down Expand Up @@ -72,16 +72,19 @@ const Editor = memo(
// Monaco does not auto-size horizontally either, so we read the current
// container width (falling back to the editor DOM node) and pass both
// dimensions through layout().
// Measure the container width; never fall back to window.innerWidth
// because that causes Monaco to set an oversized internal width which
// triggers a feedback loop pushing buttons off-screen.
// IMPORTANT: Never fall back to window.innerWidth - doing so can cause
// Monaco to expand the container, pushing sibling elements off-screen.
// If width is unmeasurable, skip layout; the resize observer effect will
// re-layout once a real width is available.
const measuredWidth =
containerRef.current?.clientWidth ??
editorRef.current.getContainerDomNode?.().clientWidth ??
0;
const fallbackWidth = editorRef.current.getLayoutInfo?.()?.width ?? 0;
const width = measuredWidth || fallbackWidth;
if (!width) {
// Don't poison layout with a huge width; the resize observer effect
// will re-layout once width is known.
return;
}
editorRef.current.layout?.({ width, height: desiredHeight });
Expand Down Expand Up @@ -140,7 +143,7 @@ const Editor = memo(
style={{ contain: "inline-size" }}
ref={setContainerRef}
>
<div className="rounded-md overflow-hidden border border-gray-200">
<div className="rounded-t-md overflow-hidden">
<MonacoEditor
key={id}
height={`${height}px`}
Expand Down
73 changes: 57 additions & 16 deletions app/src/components/Actions/MarkdownCell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import {
memo,
useCallback,
useEffect,
useMemo,
useState,
useSyncExternalStore,
Expand All @@ -38,7 +39,7 @@ import { fontSettings } from "./CellConsole";

/**
* Custom markdown components for consistent styling.
* These match the Colab-inspired styling used elsewhere in the app.
* These match the styling used elsewhere in the app for markdown rendering.
*/
const markdownComponents: Components = {
h1: ({ children, ...props }) => (
Expand Down Expand Up @@ -153,6 +154,7 @@ const markdownComponents: Components = {
};

interface MarkdownCellProps {
/** The CellData object containing the markdown content and state */
cellData: CellData;
}

Expand All @@ -171,84 +173,119 @@ const MarkdownCell = memo(
const cell = useSyncExternalStore(
useCallback(
(listener) => cellData.subscribeToContentChange(listener),
[cellData],
[cellData]
),
useCallback(() => cellData.snapshot, [cellData]),
useCallback(() => cellData.snapshot, [cellData]),
useCallback(() => cellData.snapshot, [cellData])
);

// `rendered` state controls whether we show rendered markdown or the editor.
// Start in rendered mode unless the cell is empty (new cell).
// `rendered` state controls whether we show rendered markdown or the editor
// Start in rendered mode unless the cell is empty (new cell)
const [rendered, setRendered] = useState(() => {
const value = cell?.value ?? "";
return value.trim().length > 0;
});

// Get the current cell value
const value = cell?.value ?? "";

/** Switch to edit mode on double-click. */
// Enforce invariant: empty cells must be in edit mode.
// This handles external changes (undo/redo, sync) that clear the value.
useEffect(() => {
if (!value.trim() && rendered) {
setRendered(false);
}
}, [value, rendered]);

/**
* Handle switching to edit mode when user double-clicks rendered content.
*/
const handleDoubleClick = useCallback(() => {
setRendered(false);
}, []);

/** Allow Enter/Space to activate edit mode for accessibility. */
/**
* Handle keyboard activation on the rendered container for accessibility.
* Enter or Space activates edit mode (like a button).
* Only triggers when the container itself has focus, not nested elements
* (e.g., links inside the markdown).
*/
const handleRenderedKeyDown = useCallback(
(event: KeyboardEvent<HTMLDivElement>) => {
// Only handle if the container itself is focused, not nested elements
if (event.target !== event.currentTarget) {
return;
}
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setRendered(false);
}
},
[],
[]
);

/**
* Switch back to rendered mode when focus leaves the editor container.
* Handle blur event - switch back to rendered mode when clicking away.
* Uses relatedTarget to check if focus moved outside the editor container.
* Empty cells stay in edit mode.
*/
const handleBlur = useCallback(
(event: FocusEvent<HTMLDivElement>) => {
// If focus moved to an element still inside the editor container, don't render
if (event.currentTarget.contains(event.relatedTarget as Node | null)) {
return;
}
// If content is empty, stay in edit mode
if (!value.trim()) {
return;
}
setRendered(true);
},
[value],
[value]
);

/** Escape key switches back to rendered mode (if cell has content). */
/**
* Handle keyboard events on the editor container.
* Escape key switches back to rendered mode (if not empty).
*/
const handleEditorKeyDown = useCallback(
(event: KeyboardEvent<HTMLDivElement>) => {
if (event.key === "Escape") {
// Only render if there's content; empty cells stay in edit mode
if (value.trim()) {
setRendered(true);
}
}
},
[value],
[value]
);

/** Update cell data when editor content changes. */
/**
* Handle content changes from the editor.
* Updates the cell data.
*/
const handleEditorChange = useCallback(
(newValue: string) => {
if (!cell) return;
const updated = create(parser_pb.CellSchema, cell);
updated.value = newValue;
cellData.update(updated);
},
[cell, cellData],
[cell, cellData]
);

/** "Run" on a markdown cell just renders it (matches Jupyter Shift+Enter). */
/**
* Handle "run" action for markdown cells - just renders the markdown.
* This matches Jupyter's behavior where Shift+Enter on a markdown cell
* renders it and moves to the next cell.
*/
const handleRun = useCallback(() => {
if (value.trim()) {
setRendered(true);
}
}, [value]);

// Memoize the rendered markdown to avoid unnecessary re-renders
const renderedMarkdown = useMemo(() => {
if (!value.trim()) {
return (
Expand Down Expand Up @@ -281,18 +318,21 @@ const MarkdownCell = memo(
data-rendered={rendered}
>
{rendered ? (
// Rendered markdown view - double-click or keyboard to edit
<div
id={`markdown-rendered-${cell.refId}`}
className="cursor-text rounded-md border border-transparent hover:border-gray-200 hover:bg-gray-50/50 p-4 transition-colors"
onDoubleClick={handleDoubleClick}
onKeyDown={handleRenderedKeyDown}
tabIndex={0}
role="button"
aria-label="Double-click or press Enter to edit markdown"
data-testid="markdown-rendered"
>
{renderedMarkdown}
</div>
) : (
// Editor view - blur or Escape to render
<div
id={`markdown-editor-${cell.refId}`}
className="rounded-md border border-sky-200 overflow-hidden w-full min-w-0 max-w-full"
Expand All @@ -319,8 +359,9 @@ const MarkdownCell = memo(
);
},
(prevProps, nextProps) => {
// Skip re-render if the cellData reference hasn't changed
return prevProps.cellData === nextProps.cellData;
},
}
);

MarkdownCell.displayName = "MarkdownCell";
Expand Down
Loading