Add contextual pointers to the AI assistant in the editor gutter (#36643)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: jessdomanska <87976015+jessdomanska@users.noreply.github.com>
GitOrigin-RevId: 0df858dae9673868f02ca32cea024fe89794b544
This commit is contained in:
Alf Eaton
2026-09-17 12:02:41 +00:00
committed by Copybot
co-authored by Claude Fable 5 jessdomanska
parent bb7dfcd588
commit 34d57a87f9
6 changed files with 272 additions and 0 deletions
@@ -648,6 +648,7 @@ const _ProjectController = {
'themed-modals',
'intermittent-connection-improvements',
'symbol-recognition',
'ai-assistant-pointer',
].filter(Boolean)
const getUserValues = async userId =>
@@ -190,6 +190,10 @@
"ask_proj_owner_to_upgrade_for_full_history": "",
"ask_repo_owner_to_reconnect": "",
"ask_repo_owner_to_renew_overleaf_subscription": "",
"assistant_pointer_ask_ai": "",
"assistant_pointer_hint_environment": "",
"assistant_pointer_hint_package": "",
"assistant_pointer_hint_package_plural": "",
"at_most_x_libraries_can_be_selected": "",
"at_most_x_libraries_can_be_selected_plural": "",
"attach_image_or_pdf": "",
+4
View File
@@ -243,6 +243,10 @@
"ask_proj_owner_to_upgrade_for_full_history": "Please ask the project owner to upgrade to access this project’s full history.",
"ask_repo_owner_to_reconnect": "Ask the GitHub repository owner (<0>__repoOwnerEmail__</0>) to reconnect the project.",
"ask_repo_owner_to_renew_overleaf_subscription": "Ask the GitHub repository owner (<0>__repoOwnerEmail__</0>) to renew their __appName__ subscription and reconnect the project.",
"assistant_pointer_ask_ai": "Ask the AI assistant",
"assistant_pointer_hint_environment": "Ask the AI assistant to change this __name__",
"assistant_pointer_hint_package": "Ask the AI assistant to explain this package",
"assistant_pointer_hint_package_plural": "Ask the AI assistant to explain these packages",
"at_most_x_libraries_can_be_selected": "At most __count__ libraries can be selected",
"attach_image_or_pdf": "Attach image or PDF",
"audit_logging": "Audit logging",
@@ -0,0 +1,159 @@
import {
EditorProviders,
makeEditorPropertiesProvider,
makeProjectProvider,
USER_EMAIL,
USER_ID,
} from '../../../helpers/editor-providers'
import CodemirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor'
import { mockScope } from '../helpers/mock-scope'
import { TestContainer } from '../helpers/test-container'
import { mockProject } from '../helpers/mock-project'
const mountEditor = (content: string) => {
const scope = mockScope(content)
const project = mockProject()
cy.mount(
<TestContainer>
<EditorProviders
scope={scope}
user={{
id: USER_ID,
email: USER_EMAIL,
signUpDate: '2025-10-10T10:10:10Z',
hasPaidSubscription: true,
}}
providers={{
ProjectProvider: makeProjectProvider(project),
EditorPropertiesProvider: makeEditorPropertiesProvider({
showVisual: false,
showSymbolPalette: false,
}),
}}
>
<CodemirrorEditor />
</EditorProviders>
</TestContainer>
)
// wait for the content to be parsed and revealed
cy.get('.cm-content').should('have.css', 'opacity', '1')
}
describe('assistant pointer', function () {
beforeEach(function () {
window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
window.metaAttributesCache.set('ol-showAiFeatures', true)
window.metaAttributesCache.set('ol-hasAiFreeTier', false)
window.metaAttributesCache.set('ol-splitTestVariants', {
'ai-assistant-pointer': 'tooltip',
})
cy.interceptEvents()
cy.interceptMetadata()
})
const content = [
'\\begin{table}',
' cell',
'\\end{table}',
'',
'text outside',
'',
].join('\n')
it('shows the gutter icon when the cursor is inside a table environment', function () {
mountEditor(content)
cy.contains('.cm-line', 'cell').click()
cy.findByRole('button', {
name: 'Ask the AI assistant',
hidden: true,
}).should('exist')
})
it('hides the gutter icon when the cursor leaves the environment', function () {
mountEditor(content)
cy.contains('.cm-line', 'text outside').click()
cy.findByRole('button', {
name: 'Ask the AI assistant',
hidden: true,
}).should('not.exist')
})
it('dispatches the prompt event when the gutter icon is clicked', function () {
mountEditor(content)
const listener = cy.stub().as('openWithPrompt')
cy.window().then(win => {
win.addEventListener('ui:open-workbench-with-prompt', listener)
})
cy.contains('.cm-line', 'cell').click()
cy.findByRole('button', {
name: 'Ask the AI assistant',
hidden: true,
}).click()
cy.get('@openWithPrompt').should('have.been.calledWithMatch', {
detail: { prompt: "I'd like some help with this table: " },
})
})
it('shows a hint tooltip on hover', function () {
mountEditor(content)
cy.contains('.cm-line', 'cell').click()
cy.findByRole('button', {
name: 'Ask the AI assistant',
hidden: true,
}).trigger('mouseover')
cy.findByRole('tooltip').should(
'contain.text',
'Ask the AI assistant to change this table'
)
})
it('updates the hint when moving to a different target', function () {
mountEditor(
[
'\\usepackage{graphicx}',
'',
'\\begin{figure}',
' \\centering',
'\\end{figure}',
'',
].join('\n')
)
// click at the line start, so the cursor is inside the \usepackage node
cy.contains('.cm-line', 'graphicx').click('left')
cy.findByRole('button', {
name: 'Ask the AI assistant',
hidden: true,
}).trigger('mouseover')
cy.findByRole('tooltip').should(
'contain.text',
'Ask the AI assistant to explain this package'
)
cy.contains('.cm-line', 'centering').click()
cy.findByRole('button', {
name: 'Ask the AI assistant',
hidden: true,
}).trigger('mouseover')
cy.findByRole('tooltip').should(
'contain.text',
'Ask the AI assistant to change this figure'
)
})
})
@@ -0,0 +1,103 @@
import { expect } from 'chai'
import { EditorState } from '@codemirror/state'
import { LaTeXLanguage } from '@/features/source-editor/languages/latex/latex-language'
import { LanguageSupport, ensureSyntaxTree } from '@codemirror/language'
import { findAssistantTarget } from '@modules/workbench/frontend/js/codemirror/assistant-pointer-detection'
const makeState = (doc: string, anchor: number, head?: number) => {
const state = EditorState.create({
doc,
selection: { anchor, head },
extensions: [new LanguageSupport(LaTeXLanguage)],
})
ensureSyntaxTree(state, doc.length, 1000)
return state
}
describe('findAssistantTarget', function () {
it('finds a table environment around the cursor', function () {
const doc = '\\begin{table}\n x\n\\end{table}\n'
const result = findAssistantTarget(makeState(doc, doc.indexOf('x')))
expect(result).to.include({
kind: 'environment',
from: 0,
to: doc.indexOf('\\end{table}') + '\\end{table}'.length,
})
expect(result?.names).to.deep.equal(['table'])
expect(result?.line.from).to.equal(0)
})
it('returns null outside any environment', function () {
const doc = 'text\n\\begin{table}\n\\end{table}\n'
expect(findAssistantTarget(makeState(doc, 2))).to.be.null
})
it('returns null inside an irrelevant environment', function () {
const doc = '\\begin{quote}\n x\n\\end{quote}\n'
expect(findAssistantTarget(makeState(doc, doc.indexOf('x')))).to.be.null
})
it('finds a list environment around the cursor', function () {
const doc = '\\begin{itemize}\n \\item x\n\\end{itemize}\n'
const result = findAssistantTarget(makeState(doc, doc.indexOf('item')))
expect(result?.names).to.deep.equal(['itemize'])
})
it('finds the innermost relevant environment when nested', function () {
const doc =
'\\begin{figure}\n\\begin{tabular}{c}\nx\n\\end{tabular}\n\\end{figure}\n'
const result = findAssistantTarget(makeState(doc, doc.indexOf('x\n')))
expect(result?.names).to.deep.equal(['tabular'])
})
it('finds a starred environment', function () {
const doc = '\\begin{align*}\nx\n\\end{align*}\n'
const result = findAssistantTarget(makeState(doc, doc.indexOf('x')))
expect(result?.names).to.deep.equal(['align*'])
})
it('finds a usepackage command around the cursor', function () {
const doc = 'text\n\\usepackage[draft]{graphicx}\n'
const result = findAssistantTarget(
makeState(doc, doc.indexOf('graphicx') + 2)
)
expect(result).to.include({
kind: 'package',
from: doc.indexOf('\\usepackage'),
to: doc.indexOf('graphicx}') + 'graphicx}'.length,
})
expect(result?.names).to.deep.equal(['graphicx'])
expect(result?.line.from).to.equal(5)
})
it('parses a comma-separated package list', function () {
const doc = '\\usepackage{amsmath, amssymb}\n'
const result = findAssistantTarget(makeState(doc, doc.indexOf('amssymb')))
expect(result?.names).to.deep.equal(['amsmath', 'amssymb'])
})
it('matches a selection spanning the whole environment', function () {
const doc = 'a\n\\begin{table}\nx\n\\end{table}\nb\n'
const from = doc.indexOf('\\begin')
const to = doc.indexOf('b\n', from) - 1
const result = findAssistantTarget(makeState(doc, from, to))
expect(result?.names).to.deep.equal(['table'])
})
it('matches a selection starting directly before the environment and ending inside', function () {
const doc = 'a\n\\begin{table}\nx\n\\end{table}\nb\n'
const from = doc.indexOf('\\begin')
const result = findAssistantTarget(
makeState(doc, from, doc.indexOf('x') + 1)
)
expect(result?.names).to.deep.equal(['table'])
})
it('does not match a selection extending beyond the environment', function () {
const doc = 'a\n\\begin{table}\nx\n\\end{table}\nb\n'
const result = findAssistantTarget(
makeState(doc, doc.indexOf('x'), doc.length)
)
expect(result).to.be.null
})
})
@@ -96,6 +96,7 @@ export type EditorProvidersProps = {
| 'isProfessionalGroupPlan'
| 'isMemberOfGroupSubscription'
| 'hasInstitutionLicence'
| 'hasPaidSubscription'
| 'planCode'
>
projectId?: string