[Demo only DO NOT MERGE] feat(editor): guide users to edit code themselves via Copilot code guides#3314
[Demo only DO NOT MERGE] feat(editor): guide users to edit code themselves via Copilot code guides#3314Ethanlita wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
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' |
| 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) | ||
| })() |
There was a problem hiding this comment.
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 }
)
| 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 } | ||
|
|
There was a problem hiding this comment.
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
| // 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 } } |
There was a problem hiding this comment.
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 } }There was a problem hiding this comment.
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 \ |
There was a problem hiding this comment.
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 = (() => { |
There was a problem hiding this comment.
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 = |
There was a problem hiding this comment.
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) && !typeAlreadyPresentOr 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 |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
b27a6a8 to
ad836d1
Compare
ad836d1 to
9d55bb1
Compare
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
9d55bb1 to
2f34334
Compare
[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:
<code-drag-hint><code-type-hint><code-delete-hint><code-change-hint>How it works
ui/code-guide/index.ts) owns the single activeguide, opens/navigates to it, and watches the document for completion.
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 editselsewhere don't dismiss the hint.
delete/change— region-local: an invisible Monaco decoration tracksthe target range, so completion only inspects that region's own content and
stays correct even when the user edits elsewhere in the file.
deletecompletes when the region is emptied;
changecompletes when the regionequals the new code.
diffEdges/trimCommonLinestrim shared prefix/suffixso a change like
step 160→step 200highlights only160→200; thechat shows the full diff while the editor shows the trimmed one.
editor/copilot/use-code-guide.ts) — re-show the guideas 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
editor/copilot/; the editor-side controller andrenderer live under
xgo-code-editor/ui/code-guide/. Features drive the editorvia extension points; the editor infra does not depend on feature modules.
changeusesAlwaysGrowsWhenTypingAtEdgesstickiness (so the tracked region grows to cover the replacement as the user
types it), while
deleteusesNeverGrowsWhenTypingAtEdges(so an insert justbefore the highlighted lines pushes them down instead of being swallowed).
Testing
npm run type-check,npm run lint,prettier— all clean.ui/code-guide/index.test.tscovers 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.
Limitations / follow-ups
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.