Skip to content

feat(playground): search with grit in the playground#3895

Draft
dyc3 wants to merge 1 commit intomainfrom
dyc3/grit-in-playground
Draft

feat(playground): search with grit in the playground#3895
dyc3 wants to merge 1 commit intomainfrom
dyc3/grit-in-playground

Conversation

@dyc3
Copy link
Contributor

@dyc3 dyc3 commented Jan 30, 2026

Summary

This PR makes it so that users can test grit queries in the playground.

Also, this will let users file bugs for grit query issues easier.

A lot of this was AI generated by glm-4.7

@netlify
Copy link

netlify bot commented Jan 30, 2026

Deploy Preview for biomejs failed. Why did it fail? →

Name Link
🔨 Latest commit 25015d8
🔍 Latest deploy log https://app.netlify.com/projects/biomejs/deploys/697d1e2e4527d40008bc4d95

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Jan 30, 2026

Walkthrough

This pull request integrates GritQL query functionality into the playground. Changes span the editor (CodeMirror.tsx with decoration-based highlighting), state management (types.ts, PlaygroundLoader.tsx), worker communication (biomeWorker.ts), and UI components (Playground.tsx, DiagnosticsPane.tsx, new GritQLSearchTab.tsx). A new utility function handles byte-to-code-unit offset conversion. Styling rules are added via _gritql.css. The implementation passes query matches through the component hierarchy, syncs state across editor and diagnostics pane, and enables interactive match navigation.

Suggested reviewers

  • ematipico
🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarises the main change: adding grit query search functionality to the playground.
Description check ✅ Passed The PR description clearly explains the motivation: enabling users to test grit queries in the playground and making it easier to file bug reports.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch dyc3/grit-in-playground

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 6

🤖 Fix all issues with AI agents
In `@src/playground/CodeMirror.tsx`:
- Around line 133-135: The code currently only returns gritQueryMatchesField
when gritQueryMatches.length > 0, so when gritQueryMatches is defined but empty
the DecorationField (gritQueryMatchesField) is omitted and cannot later receive
annotations; change the condition to include gritQueryMatchesField whenever
gritQueryMatches is defined (i.e., gritQueryMatches !== undefined or truthy but
allow empty array) so the returned extensions are [gritQueryMatchesField,
...baseExtensions] whenever gritQueryMatches exists, ensuring the field is
present to accept future annotations without needing a full rebuild; update the
branch that builds the extensions around gritQueryMatches, referencing
gritQueryMatches, gritQueryMatchesField, and baseExtensions.
- Around line 21-36: buildDecorations currently pushes Decoration objects in the
incoming match order which can be unsorted and causes Decoration.set to throw;
update buildDecorations to collect typed Decoration objects (e.g., Decoration[]
instead of any[]) and sort them by their numeric start position before calling
Decoration.set: for each match call spanInBytesToSpanInCodeUnits([from,to], doc)
to get codeUnitFrom/codeUnitTo, create the decoration via
yellowHighlight.range(codeUnitFrom, codeUnitTo), push to an array typed as
Decoration[], then sort that array by decoration.spec ? (or by the recorded
codeUnitFrom values) ascending before returning Decoration.set(sortedArray).
Ensure you reference buildDecorations, spanInBytesToSpanInCodeUnits,
yellowHighlight.range and Decoration.set when making the change.

In `@src/playground/tabs/GritQLSearchTab.tsx`:
- Around line 78-103: Replace the unstable index key on the mapped list items
with a stable key derived from the match tuple so React can track items
reliably; in the map over gritQueryResults.matches (inside GritQLSearchTab
render where spanInBytesToSpanInCodeUnits and handleMatchClick are used),
compute a key from the match tuple (e.g. combine match[0] and match[1] into a
string like `${match[0]}-${match[1]}` or use match.join("-")) and use that
instead of index, ensuring uniqueness for each [number, number] byte-range
match.
- Around line 87-99: The clickable list item in the GritQLSearchTab component
lacks accessibility attributes; update the <li> rendered inside the match list
(the element using onClick and onKeyDown and calling handleMatchClick(match)) to
include role="button" and tabIndex={0} so it is announced and focusable by
assistive tech/keyboard users; keep the existing onKeyDown behavior and ensure
the same element still calls handleMatchClick(match) on Enter/Space.

In `@src/playground/utils.ts`:
- Around line 428-454: byteOffsetToCodeUnitOffset miscounts UTF-8 4-byte
characters because it uses text.codePointAt(codeUnitIndex) but doesn't advance
past the low surrogate; update byteOffsetToCodeUnitOffset to mirror the
surrogate handling used in spanInBytesToSpanInCodeUnits: when you compute
byteLength and determine it equals 4, increment the codeUnitIndex an extra time
(skip the low surrogate) before continuing the loop (or reuse/call
spanInBytesToSpanInCodeUnits logic) so byteIndex and returned codeUnitIndex
correctly reflect surrogate pairs (function: byteOffsetToCodeUnitOffset;
related: spanInBytesToSpanInCodeUnits).

In `@src/playground/workers/biomeWorker.ts`:
- Around line 428-461: The current block swallows errors from
parsePattern/searchPattern/dropPattern so BiomeOutput.gritQuery.error is never
set; modify the logic around gritQueryMatches/gritQuery so that any caught
exception from workspace.parsePattern, workspace.searchPattern, or
workspace.dropPattern is captured and assigned to the output (e.g., set
BiomeOutput.gritQuery.error to the error message or object) and only continue to
subsequent steps when safe (use patternId only if parse succeeds); update error
handling in the try/catch around parsePattern, searchPattern, and dropPattern to
populate BiomeOutput.gritQuery.error rather than just logging to console.
🧹 Nitpick comments (4)
src/playground/Playground.tsx (1)

184-184: Consider using useRef instead of createRef.

createRef creates a new ref object on every render, whereas useRef persists across renders. This is a pre-existing pattern in the file, but worth noting if you're touching this area.

src/playground/tabs/GritQLSearchTab.tsx (2)

80-84: Repeated byte-to-code-unit conversion on every render.

spanInBytesToSpanInCodeUnits is called here during render and again in handleMatchClick. For large match lists, consider memoising the converted spans to avoid redundant computation.


98-98: Snippet may render trailing "..." even when full text is shown.

When the match is shorter than 50 characters, the ellipsis is misleading. Consider conditionally appending it only when truncation occurs.

Proposed fix
-                               <span className="gritql-match-snippet">{snippet}...</span>
+                               <span className="gritql-match-snippet">
+                                   {snippet}{to - from > 50 ? "..." : ""}
+                               </span>
src/playground/PlaygroundLoader.tsx (1)

257-264: Function call in dependency array may cause unnecessary re-renders.

getCurrentCode(state) and getFileState(state, state.currentFile)?.gritQuery are function calls that compute new values on every render, potentially triggering the effect more than intended. Consider extracting these to variables or using useMemo for the values.

Comment on lines 21 to 36
function buildDecorations(
matches: [number, number][],
doc: string,
): DecorationSet {
const decorations: any[] = [];

for (const [from, to] of matches) {
const [codeUnitFrom, codeUnitTo] = spanInBytesToSpanInCodeUnits(
[from, to],
doc,
);
decorations.push(yellowHighlight.range(codeUnitFrom, codeUnitTo));
}

return Decoration.set(decorations);
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Decorations must be sorted by position for Decoration.set.

CodeMirror's Decoration.set requires decorations to be sorted by from position. If matches arrive out of order, this will throw a runtime error.

Proposed fix
 function buildDecorations(
 	matches: [number, number][],
 	doc: string,
 ): DecorationSet {
-	const decorations: any[] = [];
+	const decorations: { from: number; to: number }[] = [];
 
 	for (const [from, to] of matches) {
 		const [codeUnitFrom, codeUnitTo] = spanInBytesToSpanInCodeUnits(
 			[from, to],
 			doc,
 		);
-		decorations.push(yellowHighlight.range(codeUnitFrom, codeUnitTo));
+		decorations.push({ from: codeUnitFrom, to: codeUnitTo });
 	}
 
-	return Decoration.set(decorations);
+	decorations.sort((a, b) => a.from - b.from);
+	return Decoration.set(decorations.map((d) => yellowHighlight.range(d.from, d.to)));
 }

This also addresses the pipeline warning about any[] by using a typed intermediate array.

🧰 Tools
🪛 GitHub Actions: Integrate

[warning] 25-25: Unexpected any type. Consider a more specific type instead of any.

🤖 Prompt for AI Agents
In `@src/playground/CodeMirror.tsx` around lines 21 - 36, buildDecorations
currently pushes Decoration objects in the incoming match order which can be
unsorted and causes Decoration.set to throw; update buildDecorations to collect
typed Decoration objects (e.g., Decoration[] instead of any[]) and sort them by
their numeric start position before calling Decoration.set: for each match call
spanInBytesToSpanInCodeUnits([from,to], doc) to get codeUnitFrom/codeUnitTo,
create the decoration via yellowHighlight.range(codeUnitFrom, codeUnitTo), push
to an array typed as Decoration[], then sort that array by decoration.spec ? (or
by the recorded codeUnitFrom values) ascending before returning
Decoration.set(sortedArray). Ensure you reference buildDecorations,
spanInBytesToSpanInCodeUnits, yellowHighlight.range and Decoration.set when
making the change.

Comment on lines +133 to +135
if (gritQueryMatches && gritQueryMatches.length > 0) {
return [gritQueryMatchesField, ...baseExtensions];
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Decoration field not included when gritQueryMatches is empty.

The condition gritQueryMatches.length > 0 means the field is omitted when no matches exist. If matches later become non-empty, the field won't be present to receive the annotation, causing the highlights to not appear until a full extension rebuild. Consider always including the field when gritQueryMatches is defined (even if empty).

Proposed fix
-		if (gritQueryMatches && gritQueryMatches.length > 0) {
+		if (gritQueryMatches !== undefined) {
 			return [gritQueryMatchesField, ...baseExtensions];
 		}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (gritQueryMatches && gritQueryMatches.length > 0) {
return [gritQueryMatchesField, ...baseExtensions];
}
if (gritQueryMatches !== undefined) {
return [gritQueryMatchesField, ...baseExtensions];
}
🤖 Prompt for AI Agents
In `@src/playground/CodeMirror.tsx` around lines 133 - 135, The code currently
only returns gritQueryMatchesField when gritQueryMatches.length > 0, so when
gritQueryMatches is defined but empty the DecorationField
(gritQueryMatchesField) is omitted and cannot later receive annotations; change
the condition to include gritQueryMatchesField whenever gritQueryMatches is
defined (i.e., gritQueryMatches !== undefined or truthy but allow empty array)
so the returned extensions are [gritQueryMatchesField, ...baseExtensions]
whenever gritQueryMatches exists, ensuring the field is present to accept future
annotations without needing a full rebuild; update the branch that builds the
extensions around gritQueryMatches, referencing gritQueryMatches,
gritQueryMatchesField, and baseExtensions.

Comment on lines 78 to 103
{gritQueryResults.matches.length > 0 && (
<ul className="gritql-match-list">
{gritQueryResults.matches.map((match, index) => {
const [from, to] = spanInBytesToSpanInCodeUnits(match, code);
const lines = code.substring(0, from).split("\n");
const lineNumber = lines.length;
const snippet = code.substring(from, Math.min(to, from + 50));

return (
<li
key={index}
className="gritql-match-item"
onClick={() => handleMatchClick(match)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
handleMatchClick(match);
}
}}
>
<span className="gritql-match-line">Line {lineNumber}</span>
<span className="gritql-match-snippet">{snippet}...</span>
</li>
);
})}
</ul>
)}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Use a stable key instead of array index.

The pipeline flagged this: using index as the key prop can cause issues if the list is reordered or filtered. Since matches are [number, number] tuples representing unique byte ranges, you could derive a stable key from the match itself.

Proposed fix
-                            <li
-                                key={index}
+                            <li
+                                key={`${match[0]}-${match[1]}`}
                                 className="gritql-match-item"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{gritQueryResults.matches.length > 0 && (
<ul className="gritql-match-list">
{gritQueryResults.matches.map((match, index) => {
const [from, to] = spanInBytesToSpanInCodeUnits(match, code);
const lines = code.substring(0, from).split("\n");
const lineNumber = lines.length;
const snippet = code.substring(from, Math.min(to, from + 50));
return (
<li
key={index}
className="gritql-match-item"
onClick={() => handleMatchClick(match)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
handleMatchClick(match);
}
}}
>
<span className="gritql-match-line">Line {lineNumber}</span>
<span className="gritql-match-snippet">{snippet}...</span>
</li>
);
})}
</ul>
)}
{gritQueryResults.matches.length > 0 && (
<ul className="gritql-match-list">
{gritQueryResults.matches.map((match, index) => {
const [from, to] = spanInBytesToSpanInCodeUnits(match, code);
const lines = code.substring(0, from).split("\n");
const lineNumber = lines.length;
const snippet = code.substring(from, Math.min(to, from + 50));
return (
<li
key={`${match[0]}-${match[1]}`}
className="gritql-match-item"
onClick={() => handleMatchClick(match)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
handleMatchClick(match);
}
}}
>
<span className="gritql-match-line">Line {lineNumber}</span>
<span className="gritql-match-snippet">{snippet}...</span>
</li>
);
})}
</ul>
)}
🧰 Tools
🪛 GitHub Actions: Integrate

[warning] 88-89: Avoid using the index of an array as key property in a list (noArrayIndexKey).

🤖 Prompt for AI Agents
In `@src/playground/tabs/GritQLSearchTab.tsx` around lines 78 - 103, Replace the
unstable index key on the mapped list items with a stable key derived from the
match tuple so React can track items reliably; in the map over
gritQueryResults.matches (inside GritQLSearchTab render where
spanInBytesToSpanInCodeUnits and handleMatchClick are used), compute a key from
the match tuple (e.g. combine match[0] and match[1] into a string like
`${match[0]}-${match[1]}` or use match.join("-")) and use that instead of index,
ensuring uniqueness for each [number, number] byte-range match.

Comment on lines 87 to 99
<li
key={index}
className="gritql-match-item"
onClick={() => handleMatchClick(match)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
handleMatchClick(match);
}
}}
>
<span className="gritql-match-line">Line {lineNumber}</span>
<span className="gritql-match-snippet">{snippet}...</span>
</li>
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Missing accessibility attributes on interactive list item.

The <li> is clickable and keyboard-navigable but lacks role="button" and tabIndex={0} for screen reader and keyboard users.

Proposed fix
                             <li
                                 key={`${match[0]}-${match[1]}`}
                                 className="gritql-match-item"
+                                role="button"
+                                tabIndex={0}
                                 onClick={() => handleMatchClick(match)}
                                 onKeyDown={(e) => {
🧰 Tools
🪛 GitHub Actions: Integrate

[warning] 88-89: Avoid using the index of an array as key property in a list (noArrayIndexKey).

🤖 Prompt for AI Agents
In `@src/playground/tabs/GritQLSearchTab.tsx` around lines 87 - 99, The clickable
list item in the GritQLSearchTab component lacks accessibility attributes;
update the <li> rendered inside the match list (the element using onClick and
onKeyDown and calling handleMatchClick(match)) to include role="button" and
tabIndex={0} so it is announced and focusable by assistive tech/keyboard users;
keep the existing onKeyDown behavior and ensure the same element still calls
handleMatchClick(match) on Enter/Space.

Comment on lines +428 to +454
export function byteOffsetToCodeUnitOffset(
byteOffset: number,
text: string,
): number {
let byteIndex = 0;
let codeUnitIndex = 0;

for (codeUnitIndex = 0; codeUnitIndex < text.length; codeUnitIndex++) {
const codePoint = text.codePointAt(codeUnitIndex) ?? 0;
const byteLength =
codePoint <= 0x7f
? 1
: codePoint <= 0x7ff
? 2
: codePoint <= 0xffff
? 3
: 4;

if (byteIndex + byteLength > byteOffset) {
break;
}

byteIndex += byteLength;
}

return codeUnitIndex;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Surrogate pair handling is incorrect for 4-byte UTF-8 characters.

When iterating with codePointAt(), encountering a high surrogate returns the full code point (4-byte UTF-8). However, the loop doesn't skip the subsequent low surrogate, causing incorrect byte counting for characters like emojis.

The existing spanInBytesToSpanInCodeUnits handles this by incrementing currCodeUnitIndex when byteLength === 4. Consider reusing that function or applying a similar fix here.

🐛 Proposed fix to handle surrogate pairs
 export function byteOffsetToCodeUnitOffset(
 	byteOffset: number,
 	text: string,
 ): number {
 	let byteIndex = 0;
 	let codeUnitIndex = 0;

 	for (codeUnitIndex = 0; codeUnitIndex < text.length; codeUnitIndex++) {
 		const codePoint = text.codePointAt(codeUnitIndex) ?? 0;
 		const byteLength =
 			codePoint <= 0x7f
 				? 1
 				: codePoint <= 0x7ff
 					? 2
 					: codePoint <= 0xffff
 						? 3
 						: 4;

 		if (byteIndex + byteLength > byteOffset) {
 			break;
 		}

 		byteIndex += byteLength;
+
+		// Skip the low surrogate for 4-byte characters (surrogate pairs)
+		if (byteLength === 4) {
+			codeUnitIndex++;
+		}
 	}

 	return codeUnitIndex;
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function byteOffsetToCodeUnitOffset(
byteOffset: number,
text: string,
): number {
let byteIndex = 0;
let codeUnitIndex = 0;
for (codeUnitIndex = 0; codeUnitIndex < text.length; codeUnitIndex++) {
const codePoint = text.codePointAt(codeUnitIndex) ?? 0;
const byteLength =
codePoint <= 0x7f
? 1
: codePoint <= 0x7ff
? 2
: codePoint <= 0xffff
? 3
: 4;
if (byteIndex + byteLength > byteOffset) {
break;
}
byteIndex += byteLength;
}
return codeUnitIndex;
}
export function byteOffsetToCodeUnitOffset(
byteOffset: number,
text: string,
): number {
let byteIndex = 0;
let codeUnitIndex = 0;
for (codeUnitIndex = 0; codeUnitIndex < text.length; codeUnitIndex++) {
const codePoint = text.codePointAt(codeUnitIndex) ?? 0;
const byteLength =
codePoint <= 0x7f
? 1
: codePoint <= 0x7ff
? 2
: codePoint <= 0xffff
? 3
: 4;
if (byteIndex + byteLength > byteOffset) {
break;
}
byteIndex += byteLength;
// Skip the low surrogate for 4-byte characters (surrogate pairs)
if (byteLength === 4) {
codeUnitIndex++;
}
}
return codeUnitIndex;
}
🤖 Prompt for AI Agents
In `@src/playground/utils.ts` around lines 428 - 454, byteOffsetToCodeUnitOffset
miscounts UTF-8 4-byte characters because it uses
text.codePointAt(codeUnitIndex) but doesn't advance past the low surrogate;
update byteOffsetToCodeUnitOffset to mirror the surrogate handling used in
spanInBytesToSpanInCodeUnits: when you compute byteLength and determine it
equals 4, increment the codeUnitIndex an extra time (skip the low surrogate)
before continuing the loop (or reuse/call spanInBytesToSpanInCodeUnits logic) so
byteIndex and returned codeUnitIndex correctly reflect surrogate pairs
(function: byteOffsetToCodeUnitOffset; related: spanInBytesToSpanInCodeUnits).

Comment on lines +428 to +461
let gritQueryMatches: [number, number][] = [];
if (gritQuery) {
let patternId: string | null = null;
try {
const parseResult = workspace.parsePattern({
pattern: String(gritQuery),
defaultLanguage: defaultLanguage || "JavaScript",
});
patternId = String(parseResult.patternId);
} catch (e) {
console.error("Failed to parse GritQL query:", e);
}

if (patternId) {
try {
const searchResults = workspace.searchPattern({
path: `/${filename}`,
pattern: patternId,
projectKey,
});
gritQueryMatches = searchResults.matches || [];
} catch (e) {
console.error("Failed to search with GritQL query:", e);
}

try {
workspace.dropPattern({
pattern: patternId,
});
} catch (e) {
console.warn("Failed to drop pattern:", e);
}
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Errors aren't propagated to the UI.

The BiomeOutput.gritQuery.error field exists in the types but is never populated here. Users won't see why their query failed.

🔧 Proposed fix to propagate errors
-			let gritQueryMatches: [number, number][] = [];
+			let gritQueryMatches: [number, number][] = [];
+			let gritQueryError: string | undefined;
 			if (gritQuery) {
 				let patternId: string | null = null;
 				try {
 					const parseResult = workspace.parsePattern({
 						pattern: String(gritQuery),
 						defaultLanguage: defaultLanguage || "JavaScript",
 					});
 					patternId = String(parseResult.patternId);
 				} catch (e) {
 					console.error("Failed to parse GritQL query:", e);
+					gritQueryError = e instanceof Error ? e.message : String(e);
 				}

 				if (patternId) {
 					try {
 						const searchResults = workspace.searchPattern({
 							path: `/${filename}`,
 							pattern: patternId,
 							projectKey,
 						});
 						gritQueryMatches = searchResults.matches || [];
 					} catch (e) {
 						console.error("Failed to search with GritQL query:", e);
+						gritQueryError = e instanceof Error ? e.message : String(e);
 					}

Then update the output:

 				gritQuery: {
 					matches: gritQueryMatches,
+					error: gritQueryError,
 				},
🤖 Prompt for AI Agents
In `@src/playground/workers/biomeWorker.ts` around lines 428 - 461, The current
block swallows errors from parsePattern/searchPattern/dropPattern so
BiomeOutput.gritQuery.error is never set; modify the logic around
gritQueryMatches/gritQuery so that any caught exception from
workspace.parsePattern, workspace.searchPattern, or workspace.dropPattern is
captured and assigned to the output (e.g., set BiomeOutput.gritQuery.error to
the error message or object) and only continue to subsequent steps when safe
(use patternId only if parse succeeds); update error handling in the try/catch
around parsePattern, searchPattern, and dropPattern to populate
BiomeOutput.gritQuery.error rather than just logging to console.

@dyc3 dyc3 marked this pull request as draft January 30, 2026 21:00
@dyc3 dyc3 force-pushed the dyc3/grit-in-playground branch from a15b47e to 25015d8 Compare January 30, 2026 21:10
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