Skip to content

[Demo only DO NOT MERGE] feat(editor): guide users to edit code themselves via Copilot code guides#3314

Draft
Ethanlita wants to merge 1 commit into
goplus:devfrom
Ethanlita:feat/copilot-code-guide
Draft

[Demo only DO NOT MERGE] feat(editor): guide users to edit code themselves via Copilot code guides#3314
Ethanlita wants to merge 1 commit into
goplus:devfrom
Ethanlita:feat/copilot-code-guide

Conversation

@Ethanlita

@Ethanlita Ethanlita commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

[Demo only DO NOT MERGE]

Summary

Adds an in-editor "code guide" feature for Copilot. Rather than applying code
patches automatically for the user (an "Apply" button), Copilot now guides the user to
perform the edit themselves in the editor. This keeps children in the loop and
practicing the ability to build, consistent with XBuilder's goals.

Only one guide is active at a time. Each guide navigates the editor to its
target, renders an overlay there, and clears itself automatically once the user
has completed the suggested action (compared whitespace-insensitively).

What's new

Four Copilot markdown elements, each rendered as an in-editor overlay:

Element Use Overlay
<code-drag-hint> Insert a new statement that exists as a draggable API item Drop target at the line + highlights the matching API reference item
<code-type-hint> Type a brand-new line by hand Pre-indented blank line + "Type the code here" chip + green reference ghost
<code-delete-hint> Delete code Red range highlight
<code-change-hint> Replace existing code (inline or whole-line) Red deletion + green addition shown together

How it works

  • CodeGuideController (ui/code-guide/index.ts) owns the single active
    guide, opens/navigates to it, and watches the document for completion.
  • Completion detection
    • type — the target code appears anywhere (substring, whitespace-insensitive);
      skipped if the code already exists to avoid false positives.
    • drag — a new statement of the expected kind appears (counted), so edits
      elsewhere don't dismiss the hint.
    • delete / changeregion-local: an invisible Monaco decoration tracks
      the target range, so completion only inspects that region's own content and
      stays correct even when the user edits elsewhere in the file. delete
      completes when the region is emptied; change completes when the region
      equals the new code.
  • Diff narrowingdiffEdges / trimCommonLines trim shared prefix/suffix
    so a change like step 160step 200 highlights only 160200; the
    chat shows the full diff while the editor shows the trimmed one.
  • Shared composables (editor/copilot/use-code-guide.ts) — re-show the guide
    as the streamed reply settles (deduped by signature), reopen it on click,
    skip when already satisfied, clean up on unmount, and manage the temporary
    pre-indented blank line for typing (removed again if left empty).

Architecture notes

  • Feature elements live under editor/copilot/; the editor-side controller and
    renderer live under xgo-code-editor/ui/code-guide/. Features drive the editor
    via extension points; the editor infra does not depend on feature modules.
  • A subtle but important detail: change uses AlwaysGrowsWhenTypingAtEdges
    stickiness (so the tracked region grows to cover the replacement as the user
    types it), while delete uses NeverGrowsWhenTypingAtEdges (so an insert just
    before the highlighted lines pushes them down instead of being swallowed).

Testing

  • npm run type-check, npm run lint, prettier — all clean.
  • Unit tests: ui/code-guide/index.test.ts covers completion for every kind,
    diff helpers, drag count-based completion, and the displacement (cleared)
    event. delete/change use a Monaco-like decoration mock and explicitly verify
    that completion still fires after the user edits elsewhere first.
  • Full suite: 712/712 passing.

Limitations / follow-ups

  • delete/change completion relies on Monaco's documented tracked-range stickiness.
    The unit mock encodes that behavior and the API usage type-checks, but the real
    in-editor replace gestures (select-and-retype vs. char-by-char) are worth a
    quick manual confirmation in the browser.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a robust set of copilot-driven in-editor code guidance elements (code-drag-hint, code-type-hint, and code-delete-hint) and refactors CodeChange.vue to use a shared use-code-guide.ts hook. These components guide users to perform edits manually in the editor (dragging, typing, or deleting) with reactive completion tracking managed by a new CodeGuideController. The review feedback highlights critical issues regarding initialization and safety guards: CodeDeleteHint.vue needs to import ref and watch and capture codeToDelete reactively rather than synchronously via an IIFE to prevent empty values. Additionally, bounds checks are required in CodeChange.vue to prevent out-of-bounds line access, and in use-code-guide.ts to avoid passing an invalid 0-based line number to Monaco when removing lines in single-line documents.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

</script>

<script setup lang="ts">
import { computed } from 'vue'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

To support reactive state and watching the editor/document initialization, we need to import ref and watch from Vue.

import { computed, ref, watch } from 'vue'

Comment on lines +73 to +79
const codeToDelete = (() => {
const codeEditor = codeEditorRef.value
if (codeEditor == null || range.value == null) return ''
const textDocument = codeEditor.getTextDocument(textDocumentId.value)
if (textDocument == null) return ''
return textDocument.getValueInRange(range.value)
})()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Evaluating codeToDelete synchronously during setup using an IIFE will result in an empty string ('') because codeEditorRef.value is typically null or the document is not yet loaded during the synchronous setup phase. Since it is not reactive, it will never update, which breaks the deleted code preview and the isSatisfied check.

We should use a reactive ref and a watch to capture the code once the editor and document are available, and then stop watching to keep it frozen.

const codeToDelete = ref('')
const unwatch = watch(
  () => {
    const codeEditor = codeEditorRef.value
    if (codeEditor == null || range.value == null) return null
    const textDocument = codeEditor.getTextDocument(textDocumentId.value)
    if (textDocument == null) return null
    return textDocument.getValueInRange(range.value)
  },
  (val) => {
    if (val != null && val !== '') {
      codeToDelete.value = val
      unwatch()
    }
  },
  { immediate: true }
)

Comment on lines +98 to +103
const changeBase = computed<{ range: Range; oldText: string } | null>(() => {
const codeEditor = codeEditorRef.value
if (codeEditor == null) return null
const textDocument = codeEditor.getTextDocument(getTextDocumentId(props.file))
if (codeEditor == null || isNaN(startLine.value) || isNaN(removeLineCount.value)) return null
const textDocument = codeEditor.getTextDocument(textDocumentId.value)
if (textDocument == null) return null
const startLine = parseInt(props.line, 10)
const removeLineCount = props.removeLineCount == null ? 0 : parseInt(props.removeLineCount, 10)
if (isNaN(startLine) || isNaN(removeLineCount)) return null
const endLine = startLine + removeLineCount
const range: Range = {
start: { line: startLine, column: 1 },
end: { line: endLine, column: 1 }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If startLine.value is out of bounds (e.g., if the document is shorter than the specified line number), calling textDocument.getLineContent(startLine.value) will throw an error or return undefined, causing a runtime exception. We should guard against this by checking if startLine.value is within the valid line range of the document.

const changeBase = computed<{ range: Range; oldText: string } | null>(() => {
  const codeEditor = codeEditorRef.value
  if (codeEditor == null || isNaN(startLine.value) || isNaN(removeLineCount.value)) return null
  const textDocument = codeEditor.getTextDocument(textDocumentId.value)
  if (textDocument == null) return null

  const lineCount = textDocument.getValue().split(/\r?\n/).length
  if (startLine.value < 1 || startLine.value > lineCount) return null

Comment on lines +175 to +179
// For a trailing line, remove the preceding newline too; otherwise remove the line and its trailing newline.
const range =
line >= lineCount
? { start: { line: line - 1, column: textDocument.getLineContent(line - 1).length + 1 }, end: { line, column: textDocument.getLineContent(line).length + 1 } }
: { start: { line, column: 1 }, end: { line: line + 1, column: 1 } }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In remove(), if lineCount is 1 and line is 1, line >= lineCount is true, so it evaluates line - 1 which is 0. Monaco line numbers are 1-based, so passing 0 as a line number will throw a runtime error. We should handle the case where lineCount <= 1 safely.

    // For a trailing line, remove the preceding newline too; otherwise remove the line and its trailing newline.
    const range =
      lineCount <= 1
        ? { start: { line: 1, column: 1 }, end: { line: 1, column: textDocument.getLineContent(1).length + 1 } }
        : line >= lineCount
        ? { start: { line: line - 1, column: textDocument.getLineContent(line - 1).length + 1 }, end: { line, column: textDocument.getLineContent(line).length + 1 } }
        : { start: { line, column: 1 }, end: { line: line + 1, column: 1 } }

@fennoai fennoai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR introduces the copilot code-guide system — new code-type-hint, code-drag-hint, code-delete-hint components, a heavily refactored CodeChange.vue (drops the Apply button in favour of guided edits), plus a shared use-code-guide.ts composable and a CodeGuideController. The design is well-structured and the test suite in index.test.ts is thorough for the controller layer.

A few issues need attention before landing:

typeAlreadyPresent permanently blocks completion (code-guide/index.ts:162)
The flag is computed once when watchCompletion is called. If the document already contains the target text at guide-open time, isCompleted() returns false forever — the guide can never be dismissed even after the user deletes and re-types the code. See inline comment.

codeToDelete captured at setup, not mount (CodeDeleteHint.vue:73)
The IIFE runs synchronously during component setup, before the code editor ref is populated. When codeEditorRef.value is null at that point, codeToDelete stays '' permanently, so the code preview is blank and isSatisfied never returns true. The same "capture once" intent should be preserved via onMounted + a ref, or watch({ once: true }).

Stale AI instruction in CodeDeleteHint.vue:detailedDescription
This string is a live LLM instruction. It describes <code-change> as having an Apply button ("edits the code automatically when the user clicks 'Apply'") — which is the behavior this PR removes. The LLM will receive contradictory information about the two elements.

new RegExp() per content-change event (code-guide/index.ts:107)
countWordOccurrences is called from isCompleted() on every keystroke while a drag guide is active. The RegExp is reconstructed each call even though word is constant for the lifetime of the guide. Cache it once at guide-open time.

requestAnimationFrame handle not stored in alignAdditionNode (CodeGuideUI.vue:260)
The rAF callbacks have no stored handle and cannot be cancelled. If the component unmounts between frames, tryAlign keeps running and accesses ui.editor.getDomNode() on a potentially disposed editor. Store the return value and cancel it in the component's cleanup.

Multiple getValue().split() per keystroke (CodeGuideUI.vue:183–197)
isNewLineInsertion and typeChipDecorations each call doc.getValue().split(/\r?\n/).length independently, and both are driven by docVersion (incremented on every content change). That is two full-document allocations + splits per keystroke. Consolidate into one call, or use a getLineCount() method if the underlying model exposes it.

export const description = 'Guide the user to delete a piece of existing code.'

export const detailedDescription = `Guide the user to delete a piece of existing code by highlighting it in red in the editor. \
Unlike <code-change> (which edits the code automatically when the user clicks "Apply"), this element does NOT modify \

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale AI instruction: This parenthetical describes <code-change> as 'editing code automatically when the user clicks Apply', but this PR removes the Apply button. The LLM will receive contradictory information about the two elements. Update to reflect the current guide-based behavior — e.g. 'Unlike <code-change> (which shows red/green diffs and guides the user to make the edit themselves)'.

})

// Capture the code to delete once; we don't want it to change as the document is edited.
const codeToDelete = (() => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This IIFE runs during component setup, before the component is mounted and before codeEditorRef.value is populated. If the ref is null at that point codeToDelete is permanently '', so the code preview shows nothing and isSatisfied at line 88 (codeToDelete !== '') always returns false — the guide can never be completed.

Capture inside onMounted (or use watch({ once: true })) to preserve the 'capture once' intent while ensuring the ref is ready:

const codeToDelete = ref('')
onMounted(() => {
  const textDocument = codeEditorRef.value?.getTextDocument(textDocumentId.value)
  if (textDocument != null && range.value != null)
    codeToDelete.value = textDocument.getValueInRange(range.value)
})

// Type: completion means the target code appears anywhere (whitespace-insensitive). If it already
// exists we cannot tell when the user types it, so never auto-complete (avoids false positives).
const typeTarget = guide.kind === 'type' ? normalizeCode(guide.code) : ''
const typeAlreadyPresent =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

typeAlreadyPresent is captured once at the time watchCompletion is called. If true, isCompleted() at line 209 permanently returns false for the lifetime of the guide — even after the user deletes and re-types the target code. The guide can never be dismissed.

The intent (avoiding false-positive auto-dismissal when the code was already in the file) is sound, but the check should be re-evaluated on each content change rather than frozen:

case 'type':
  if (typeTarget === '') return true
  // Snapshot the pre-existing occurrences once; new completion only fires if count grows.
  return normalizeCode(textDocument.getValue()).includes(typeTarget) && !typeAlreadyPresent

Or compare the occurrence count at activation time against the current count so the guide completes when the user actively types it even if it existed before.

function countWordOccurrences(text: string, word: string): number {
if (word === '') return 0
const escaped = word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
return (text.match(new RegExp(`(^|[^\\w$])${escaped}(?![\\w$])`, 'g')) ?? []).length

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

new RegExp(...) is constructed on every call. countWordOccurrences is called from isCompleted() on every didChangeContent event while a drag guide is active (i.e. every keystroke). Since word is constant for the lifetime of a single guide, compile the regex once when the guide is opened and capture it in the closure:

const dragRegex = dragName !== ''
  ? new RegExp(`(^|[^\\w$])${dragName.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')}(?![\\w$])`, 'g')
  : null

}
if (attempts++ < 10) requestAnimationFrame(tryAlign)
}
requestAnimationFrame(tryAlign)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The requestAnimationFrame handle is never stored, so there is no way to cancel these callbacks externally. If the component is unmounted between frames, tryAlign keeps firing and accesses ui.editor.getDomNode() on a potentially disposed editor.

Store the handle and cancel it in a cleanup callback:

function alignAdditionNode(node: HTMLElement, anchorSelector: string) {
  let attempts = 0
  let rafId: number | null = null
  const tryAlign = () => {
    // ...existing logic...
    if (attempts++ < 10) rafId = requestAnimationFrame(tryAlign)
  }
  rafId = requestAnimationFrame(tryAlign)
  return () => { if (rafId != null) cancelAnimationFrame(rafId) }
}

The caller (createAdditionNode) should invoke the returned cleanup in the view-zone's removal callback.

function isNewLineInsertion(startLine: number): boolean {
const doc = ui.activeTextDocument
if (doc == null) return false
const lineCount = doc.getValue().split(/\r?\n/).length

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

doc.getValue().split(/\r?\n/).length is called here and again independently in typeChipDecorations (line 197) and dragInlineDecorations. Because all three functions are driven by docVersion (bumped on every keystroke), this allocates and splits the full document string multiple times per keypress.

Consolidate into a single reactive line count, e.g.:

const lineCount = computed(() => {
  void docVersion.value
  return ui.activeTextDocument?.getValue().split(/\r?\n/).length ?? 0
})

or, if the underlying TextDocument exposes a getLineCount() method, use that directly to avoid the string allocation entirely.

Instead of auto-applying patches or simply point to the editor area using a purple arrow, Copilot now guides the user to make the
edit themselves in the editor, so children practice writing code by hand.

A reply can render four guide elements(custom elements), each backed by an in-editor overlay:

- [NEW] code-drag-hint  — opens a drop target at a line and highlights the matching
                           API reference item, for inserting a new statement by drag
- [NEW] code-type-hint  — opens a pre-indented blank line with a "Type the code here"
                           chip and a green reference ghost, for typing a new statement
- [NEW] code-delete-hint — highlights a range in red, for deleting it
- [MODIFIED] code-change-hint     — shows the deletion (red) and addition (green) at once, for
                                     replacing existing code (inline or whole-line)

The CodeGuideController keeps a single active guide, navigates to it, and
auto-dismisses it once the suggested edit is done (whitespace-insensitive).
Completion for delete/change is region-local: an invisible Monaco decoration
tracks the target range so it stays correct even when the user edits elsewhere.
Shared logic lives in use-code-guide.ts (re-show on stream settle, reopen on
click, skip-if-already-done, blank-line scaffolding) so all elements behave
consistently.

Changes to be committed:
	new file:   node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json
	modified:   spx-gui/src/components/copilot/CopilotRound.vue
	modified:   spx-gui/src/components/copilot/context.ts
	modified:   spx-gui/src/components/copilot/copilot.ts
	modified:   spx-gui/src/components/editor/copilot/CodeChange.vue
	new file:   spx-gui/src/components/editor/copilot/CodeDeleteHint.vue
	new file:   spx-gui/src/components/editor/copilot/CodeDragHint.vue
	new file:   spx-gui/src/components/editor/copilot/CodeTypeHint.vue
	modified:   spx-gui/src/components/editor/copilot/index.ts
	new file:   spx-gui/src/components/editor/copilot/use-code-guide.ts
	modified:   spx-gui/src/components/xgo-code-editor/index.ts
	modified:   spx-gui/src/components/xgo-code-editor/ui/CodeEditorUI.vue
	modified:   spx-gui/src/components/xgo-code-editor/ui/api-reference/APIReferenceItem.vue
	modified:   spx-gui/src/components/xgo-code-editor/ui/api-reference/index.ts
	modified:   spx-gui/src/components/xgo-code-editor/ui/code-editor-ui.ts
	new file:   spx-gui/src/components/xgo-code-editor/ui/code-guide/CodeGuideUI.vue
	new file:   spx-gui/src/components/xgo-code-editor/ui/code-guide/index.test.ts
	new file:   spx-gui/src/components/xgo-code-editor/ui/code-guide/index.ts
	modified:   spx-gui/src/components/xgo-code-editor/ui/common.ts
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant