Skip to content
Merged
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
147 changes: 147 additions & 0 deletions web/oss/src/styles/editor-theme.css
Original file line number Diff line number Diff line change
Expand Up @@ -776,3 +776,150 @@ pre::-webkit-scrollbar-thumb {
.dark .editor-tokenAttr {
color: #79c0ff;
}

/* ------------------------------------------------------------------------- *
* Document prose ("Option B — comfortable")
*
* Scoped to the Markdown document editors (Instructions / Skill) via the
* `md-prose` wrapper, so the shared editor theme used by the prompt and chat
* editors stays as-is. Uses antd semantic tokens (`--ant-color-*`) so it adapts
* to dark mode automatically; placed last so equal-specificity rules win over
* the `.dark` overrides above. Covers both the edit and read-only preview panes
* (both render through this Lexical editor).
* ------------------------------------------------------------------------- */
.md-prose .editor-paragraph {
margin-bottom: 10px;
line-height: 1.6;
}
.md-prose .editor-heading-h1,
.md-prose .editor-heading-h2,
.md-prose .editor-heading-h3,
.md-prose .editor-heading-h4,
.md-prose .editor-heading-h5,
.md-prose .editor-heading-h6 {
color: var(--ant-color-text);
font-weight: 600;
line-height: 1.3;
margin-top: 18px;
margin-bottom: 6px;
}
.md-prose .editor-heading-h1 {
font-size: 20px;
}
.md-prose .editor-heading-h2 {
font-size: 16px;
}
.md-prose .editor-heading-h3 {
font-size: 14px;
}
.md-prose .editor-heading-h4,
.md-prose .editor-heading-h5,
.md-prose .editor-heading-h6 {
font-size: 13px;
color: var(--ant-color-text-secondary);
}

/* Lists — the shared theme gives ol/ul different left margins and 8px-tall items, so the
two list kinds indent inconsistently. Normalize both to one padding and tight items. */
.md-prose .editor-list-ol,
.md-prose .editor-list-ul {
margin: 8px 0;
padding-left: 22px;
}
.md-prose .editor-list-item {
margin: 3px 0;
}
.md-prose .editor-nested-list-item {
margin: 0;
list-style-type: none;
}
/* Per-depth markers for nested lists, matching the Lexical playground: ordered cycles
1 → A → a → I → i, unordered • → ◦ → ▪. Lexical DOM-nests indented items (the wrapper li
carries `editor-nested-list-item`, which is marker-less above), so descendant `ol ol` /
`ul ul` selectors pick the right style per level. Scoped to the rendered document view. */
.md-prose .editor-input:not(.markdown-view) ol.editor-list-ol {
list-style-type: decimal;
}
.md-prose .editor-input:not(.markdown-view) ol.editor-list-ol ol.editor-list-ol {
list-style-type: upper-alpha;
}
.md-prose .editor-input:not(.markdown-view) ol.editor-list-ol ol.editor-list-ol ol.editor-list-ol {
list-style-type: lower-alpha;
}
.md-prose
.editor-input:not(.markdown-view)
ol.editor-list-ol
ol.editor-list-ol
ol.editor-list-ol
ol.editor-list-ol {
list-style-type: upper-roman;
}
.md-prose
.editor-input:not(.markdown-view)
ol.editor-list-ol
ol.editor-list-ol
ol.editor-list-ol
ol.editor-list-ol
ol.editor-list-ol {
list-style-type: lower-roman;
}
.md-prose .editor-input:not(.markdown-view) ul.editor-list-ul {
list-style-type: disc;
}
.md-prose .editor-input:not(.markdown-view) ul.editor-list-ul ul.editor-list-ul {
list-style-type: circle;
}
.md-prose
.editor-input:not(.markdown-view)
ul.editor-list-ul
ul.editor-list-ul
ul.editor-list-ul {
list-style-type: square;
}
.md-prose .editor-quote {
font-size: 13px;
font-style: italic;
color: var(--ant-color-text-secondary);
border-left: 3px solid var(--ant-color-border);
margin-left: 2px;
padding-left: 14px;
}
.md-prose .editor-text-code {
background-color: var(--ant-color-fill-tertiary);
border: 0.5px solid var(--ant-color-border-secondary);
border-radius: 4px;
padding: 1px 5px;
font-size: 86%;
}
/* The shared `.editor-inner:not(.code-editor) .editor-code` rule (specificity 0-3-0) sets an 8px
padding + light fill and would beat a plain `.md-prose .editor-code` (0-2-0). Scope this to the
rendered input (`:not(.markdown-view)`, so source view is untouched) to reach 0-4-0 and win. */
/* Hide the editor's built-in uppercase language label (globals.css renders it via
`::after content: attr(data-highlight-language)`, specificity 0-5-1) on document code blocks —
the CodeBlockLanguageMenu supplies an interactive picker in its place. Needs 0-6-1 to win. */
.md-prose
.editor-inner:not(.code-editor)
.editor-input:not(.markdown-view)
.editor-code::after {
content: none;
}
.md-prose .editor-input:not(.markdown-view) .editor-code {
display: block;
position: relative;
background-color: var(--ant-color-fill-quaternary);
border: 0.5px solid var(--ant-color-border-secondary);
border-radius: 8px;
/* Extra top padding leaves room for the floating language picker pinned to the block's top. */
padding: 40px 14px 12px;
margin: 10px 0;
font-family: Menlo, Consolas, Monaco, monospace;
font-size: 12px;
line-height: 1.6;
white-space: pre-wrap;
tab-size: 2;
overflow-x: auto;
}
.md-prose .editor-link {
color: var(--ant-color-primary);
text-decoration: underline;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import {type ButtonHTMLAttributes, forwardRef} from "react"

/**
* The inline "add a …" accent text-link used by every config-section empty state ("No tools yet —
* add a tool"). forwardRef + prop spread so it can be the trigger of a Popover/Dropdown (which
* inject `onClick` and a positioning ref).
*/
export const AddTextLink = forwardRef<
HTMLButtonElement,
{label: string} & ButtonHTMLAttributes<HTMLButtonElement>
>(function AddTextLink({label, type = "button", ...rest}, ref) {
return (
<button
ref={ref}
type={type}
{...rest}
className="cursor-pointer border-0 bg-transparent p-0 text-xs font-medium text-[var(--ag-c-1677FF,#1677ff)] hover:underline"
>
{label}
</button>
)
})
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,7 @@
* Sections are schema-driven: each renders only when its field exists in the resolved
* schema, so the panel tracks the backend contract instead of hard-coding fields.
*/
import {
type ButtonHTMLAttributes,
forwardRef,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react"
import {useCallback, useEffect, useMemo, useRef, useState} from "react"

import {vaultSecretsQueryAtom} from "@agenta/entities/secret"
import type {SchemaProperty} from "@agenta/entities/shared"
Expand Down Expand Up @@ -63,6 +55,7 @@ import {useAtomValue} from "jotai"

import {useOptionalDrillIn} from "../components/MoleculeDrillInContext"

import {AddTextLink} from "./AddTextLink"
import {agentTemplateLayoutAtom} from "./agentTemplateLayout"
import {ClaudePermissionsControl} from "./ClaudePermissionsControl"
import {CodeEditor} from "./CodeEditor"
Expand Down Expand Up @@ -492,30 +485,6 @@ function ItemRow({
)
}

/**
* A text-only "add" link for a section's empty state — no border/background/padding, just inline
* link text inside a muted sentence (the section header keeps the compact `+` for quick-add).
*
* forwardRef + props spread so it can be a popover trigger (ToolSelectorPopover): the popover
* clones the trigger to attach its positioning ref + onClick. Without the ref it can't anchor and
* the popover renders at the top-left and immediately closes.
*/
const AddTextLink = forwardRef<
HTMLButtonElement,
{label: string} & ButtonHTMLAttributes<HTMLButtonElement>
>(function AddTextLink({label, type = "button", ...rest}, ref) {
return (
<button
ref={ref}
type={type}
{...rest}
className="cursor-pointer border-0 bg-transparent p-0 text-xs font-medium text-[var(--ag-c-1677FF,#1677ff)] hover:underline"
>
{label}
</button>
)
})

/**
* An instructions markdown file row. Avatar + filename + a 2-line preview of the (markdown-stripped)
* content, clamped with an ellipsis. The whole row opens the editor drawer for the full content —
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
/**
* CodeBlockLanguageMenu
*
* Renders a language picker pinned to the top-right of EVERY code block in the editor (not only the
* focused one), so each block's reserved top strip always carries its language instead of leaving a
* dead gap. It mounts inside the editor's `EditorProvider`. The picker drives `CodeNode.setLanguage`,
* which the Prism highlighter (registered in MarkdownEditor) reads.
*
* Positioning: fixed-position portals anchored to each block's viewport rect, recomputed on every
* editor update plus scroll/resize. Pickers whose block has scrolled out of the editor's scroll
* viewport are dropped so they don't float over the toolbar or neighbouring UI.
*/
import {useCallback, useEffect, useMemo, useState} from "react"

import {useLexicalComposerContext} from "@agenta/ui"
import {$isCodeNode, getCodeLanguageOptions, getLanguageFriendlyName} from "@lexical/code"
import {Select} from "antd"
import {$getNodeByKey, $getRoot} from "lexical"
import {createPortal} from "react-dom"

interface BlockMenu {
key: string
lang: string
top: number
right: number
}

export function CodeBlockLanguageMenu({editable = true}: {editable?: boolean}) {
const [editor] = useLexicalComposerContext()
const options = useMemo(
() => getCodeLanguageOptions().map(([value, label]) => ({value, label})),
[],
)
const [menus, setMenus] = useState<BlockMenu[]>([])

const recompute = useCallback(() => {
const rootEl = editor.getRootElement()
// `.md-prose` is the editor's scrolling viewport (see MarkdownEditor) — clip pickers to it.
const viewport = rootEl?.closest<HTMLElement>(".md-prose")?.getBoundingClientRect()
editor.getEditorState().read(() => {
const next: BlockMenu[] = []
for (const child of $getRoot().getChildren()) {
if (!$isCodeNode(child)) continue
const el = editor.getElementByKey(child.getKey())
if (!el) continue
const rect = el.getBoundingClientRect()
// Skip blocks whose top edge isn't within the visible viewport.
if (viewport && (rect.top < viewport.top - 4 || rect.top > viewport.bottom - 10)) {
continue
}
next.push({
key: child.getKey(),
lang: child.getLanguage() ?? "",
top: rect.top,
right: window.innerWidth - rect.right,
})
}
setMenus(next)
})
}, [editor])

useEffect(() => editor.registerUpdateListener(() => recompute()), [editor, recompute])

useEffect(() => {
// Recompute after mount so existing blocks get pickers without an edit. On a cold open the
// drawer is still sliding in and the markdown is still hydrating, so the first rects are
// stale (the picker would otherwise only appear once some later event nudged a recompute —
// e.g. a hover). Re-run across the next frames + a few timeouts to settle on the real rects.
recompute()
const raf = requestAnimationFrame(recompute)
const timers = [60, 180, 360].map((t) => window.setTimeout(recompute, t))
const onMove = () => recompute()
// capture: the editor scrolls in an inner container, not the window
window.addEventListener("scroll", onMove, true)
window.addEventListener("resize", onMove)
return () => {
cancelAnimationFrame(raf)
timers.forEach(clearTimeout)
window.removeEventListener("scroll", onMove, true)
window.removeEventListener("resize", onMove)
}
}, [recompute])

const setLanguage = useCallback(
(key: string, lang: string) => {
editor.update(() => {
const node = $getNodeByKey(key)
if ($isCodeNode(node)) node.setLanguage(lang)
})
},
[editor],
)

if (!menus.length) return null

return createPortal(
<>
{menus.map((m) => (
<div
key={m.key}
className={[
"fixed z-[1100] rounded-md bg-[var(--ant-color-bg-elevated)]",
"[&_.ant-select-selector]:!h-6 [&_.ant-select-selector]:!px-2",
"[&_.ant-select-selection-item]:!text-[11px] [&_.ant-select-selection-item]:!leading-6",
"[&_.ant-select-selection-placeholder]:!text-[11px] [&_.ant-select-selection-placeholder]:!leading-6",
"[&_.ant-select-selection-search-input]:!h-6",
// Portaled to <body>, antd sets a serif fallback font on the inner item; make
// it inherit the app font set on the wrapper below.
"[&_.ant-select-selection-item]:!font-[inherit]",
"[&_.ant-select-selection-placeholder]:!font-[inherit]",
"[&_.ant-select-selection-search-input]:!font-[inherit]",
].join(" ")}
style={{
top: m.top + 7,
right: m.right + 8,
fontFamily: "var(--ant-font-family)",
}}
// Don't let interactions here reach the editor.
onMouseDown={(e) => e.stopPropagation()}
>
<Select
showSearch
disabled={!editable}
value={m.lang || undefined}
placeholder="Plain text"
options={options}
onChange={(lang) => setLanguage(m.key, lang)}
optionFilterProp="label"
popupMatchSelectWidth={false}
variant="borderless"
className="min-w-[96px]"
// Show the friendly name ("JavaScript") for the selected value, not the raw id.
labelRender={({value}) =>
value ? getLanguageFriendlyName(String(value)) : "Plain text"
}
/>
</div>
))}
</>,
document.body,
)
}
Loading
Loading