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
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,19 @@ export default class CodeBlockDiffEditorHeader extends Component<CodeBlockDiffEd
}

private get fileName() {
return new URL(this.fileUrl ?? '').pathname.split('/').pop() || '';
let fileUrl = this.fileUrl;
if (!fileUrl) {
return '';
}
try {
return new URL(fileUrl).pathname.split('/').pop() || '';
} catch {
// The model names the file it is patching, and what it writes is not
// always a URL. Falling back to the last path segment keeps a header that
// is merely wrong from taking the whole message down with it; whether the
// patch can be applied is reported separately.
return fileUrl.split('/').pop() || '';
}
Comment on lines +194 to +206

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.

[Claude Code 🤖] The guard is right, and the same raw string still reaches an unguarded new URL one component away.

Confirmation on this getter. The fallback is the correct shape: new URL('malformed file url') throws, and 'malformed file url'.split('/').pop() gives back the whole string, which is what the model wrote and therefore the most useful thing to show. The test pins exactly that.

Where it doesn't reach. this.file (the @cached getter above) builds its FileDef with sourceUrl: this.sourceUrl ?? '', and sourceUrl returns this.args.codeData.fileUrl verbatim for a non-new file — the same unparseable string. That FileDef goes straight into <AttachedFileDropdownMenu @file={{this.file}} …>, whose openInCodeMode does new URL(this.args.file.sourceUrl!) (packages/host/app/components/ai-assistant/attached-file-dropdown-menu.gts). The '' case is reachable too: sourceUrl returns null for a new file that isn't applied yet, so this.file.sourceUrl is '' and new URL('') throws the same way.

That is a click-time throw inside an @action, not a render-time one, so it does not undo what this change fixes — the message renders, which was the point. But the menu item is offered on a header whose file URL this change has just established may not be a URL, and choosing it throws.

Scope. Follow-up, non-blocking, and arguably pre-existing rather than introduced here — the '' path predates this change. Worth either guarding openInCodeMode the same way, or omitting the "open in code mode" item when sourceUrl doesn't parse, so the header degrades consistently instead of half-way.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[Claude Code 🤖] The main finding holds and I'm treating it as a follow-up rather than folding it in — but one detail of it doesn't survive checking, and it's the half that would have decided the fix.

Confirmed: openInCodeMode does new URL(this.args.file.sourceUrl!) with no guard, and for a non-new file sourceUrl hands it codeData.fileUrl verbatim. So the unparseable-but-non-empty case — malformed file url, exactly what this change made renderable — is offered as an enabled menu item that throws on click.

Not confirmed: the '' case. The menu item carries disabled: !this.args.file?.sourceUrl (attached-file-dropdown-menu.gts), and '' is falsy, so the new-file-not-yet-applied path you describe is already disabled rather than reachable. That matters for the remedy: "omit the item when sourceUrl doesn't parse" is the right shape, but it's an extension of a guard that exists, not a missing guard — the existing one just tests presence where it needs to test parseability.

Why not here. It's a different component, a click-time throw rather than a render-time one, and the fix wants its own test around the menu rather than the header. This branch has had a long CI road and I'd rather not widen it for something no current path reaches on render. Happy to do it as a follow-up — disabled: !parses(this.args.file?.sourceUrl) with a small helper is the whole change.

Agreed on the framing though: this change established that a header's file URL may not be a URL, so anything downstream taking that string as one is now on notice.

}

private get sourceUrl(): string | null {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { htmlSafe } from '@ember/template';
import Component from '@glimmer/component';
import { cached, tracked } from '@glimmer/tracking';

import { Alert } from '@cardstack/boxel-ui/components';
import { Alert, LoadingIndicator } from '@cardstack/boxel-ui/components';
import { and, bool, eq } from '@cardstack/boxel-ui/helpers';

import { markdownToHtml } from '@cardstack/runtime-common/marked-sync';
Expand Down Expand Up @@ -359,6 +359,27 @@ class HtmlGroupCodeBlock extends Component<HtmlGroupCodeBlockSignature> {
@modifiedCode={{this.codeDiffResource.modifiedCode}}
/>
</codeBlock.actions>
{{else if this.codeDiffResource.isLoadingDiff}}
{{! Together with the branch above, this makes the states a patch can
be rendered in exhaustive: once `modify` returns, the resource holds
code, or an error, or a running load. There is no fourth state, and
the empty block this branch replaced was it. A load only runs once a
file URL is known — `modify` records an error and returns without
performing when there isn't one — so the header can always name its
file here. A failed patch is not shown in this branch; the footer's
alert speaks for it. }}
<codeBlock.diffEditorHeader
@codeData={{@codeData}}
@diffEditorStats={{null}}
@originalUploadedFileUrl={{@codePatchResult.originalUploadedFileUrl}}
@codePatchStatus={{@codePatchStatus}}
@userMessageThisMessageIsRespondingTo={{@userMessageThisMessageIsRespondingTo}}
@codePatchErrorMessage={{this.codePatchErrorMessage}}
/>
<div class='code-patch-loading' data-test-code-patch-loading>
<LoadingIndicator @color='var(--boxel-light)' />
<span>Loading diff…</span>
</div>
{{/if}}

{{#if this.codePatchErrorMessage}}
Expand Down Expand Up @@ -402,5 +423,18 @@ class HtmlGroupCodeBlock extends Component<HtmlGroupCodeBlockSignature> {
{{/if}}
{{/if}}
</CodeBlock>

<style scoped>
.code-patch-loading {
display: flex;
align-items: center;
gap: var(--boxel-sp-xs);
padding: var(--boxel-sp-sm);
background-color: var(--boxel-dark);
color: var(--boxel-light);
font-size: var(--boxel-font-size-sm);
line-height: var(--boxel-line-height-sm);
}
</style>
</template>
}
134 changes: 108 additions & 26 deletions packages/host/app/resources/code-diff.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { registerDestructor } from '@ember/destroyable';
import { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';

Expand Down Expand Up @@ -27,56 +28,115 @@ export class CodeDiffResource extends Resource<CodeDiffResourceArgs> {
@tracked errorMessage: string | undefined | null = null;
codePatchStatus: CodePatchStatus | undefined | null = null;

// Untracked on purpose. `modify` runs inside the resource's tracked cache, so
// anything it reads there re-runs it when written — and it does read load
// state: `isDataLoaded` consumes `originalCode` and `modifiedCode`, and
// `errorMessage` is tracked as well. What keeps that entanglement safe is an
// invariant worth stating, because an edit can break it without looking
// wrong: every path in `modify` that reads those returns without writing
// them, so a finished load costs one further `modify` and stops. Keeping the
// mid-flight signal out of the entanglement spares a second invalidation per
// load; a path that read either one and then started a load would re-enter
// without bound. The template reads the task's own `isRunning`, which is
// tracked and safe to render from.
private loadInFlight = false;
private abortController: AbortController | undefined;

@service declare private cardService: CardService;
@service declare private toolService: ToolService;

constructor(owner: object) {
super(owner);
registerDestructor(this, () => this.abortController?.abort());
}

modify(_positional: never[], named: CodeDiffResourceArgs['named']) {
let { fileUrl, searchReplaceBlock, codePatchStatus } = named;
let fileOrPatchChanged =
this.fileUrl !== fileUrl ||
this.searchReplaceBlock !== searchReplaceBlock;
let appliedStateChanged =
this.codePatchStatus === 'applied' || codePatchStatus === 'applied';
if (fileOrPatchChanged || appliedStateChanged) {
this.originalCode = null;
this.modifiedCode = null;
}
this.errorMessage = null;
this.codePatchStatus !== codePatchStatus &&
(this.codePatchStatus === 'applied' || codePatchStatus === 'applied');
let inputsChanged = fileOrPatchChanged || appliedStateChanged;

this.fileUrl = fileUrl;
this.searchReplaceBlock = searchReplaceBlock;
this.codePatchStatus = codePatchStatus;

if (!fileUrl) {
// These arguments are recomputed on every invalidation of the room
// resource, which during streaming arrives continuously — so `modify` runs
// far more often than anything about this diff actually changes. Only a
// change to what is being diffed justifies discarding what we have and
// starting again. Restarting on every invalidation cancelled the load
// mid-flight and cleared the error along with it, leaving the resource
// holding neither code nor a message to show, and asking the realm for the
// file again each time it happened.
if (!inputsChanged) {
// `applied` is checked first, and not only for cheapness: it is a
// terminal state with no diff to fetch, and it is the one state a load
// returns from without recording either code or an error. Falling through
// to `load.perform()` in it would write the state this condition just
// read, from inside the same cache computation — dirtying the resource's
// own cache and re-entering `modify` on every read.
if (
codePatchStatus === 'applied' ||
this.isDataLoaded ||
this.errorMessage ||
this.loadInFlight
) {
return;
}
} else {
Comment on lines +75 to +90

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.

[Claude Code 🤖] This early return is correct today, and it is one line away from being an infinite invalidation loop. The thing that keeps it safe lives in a different file and is not written down anywhere.

Background — why reading tracked state here is different from reading it anywhere else. ember-modify-based-class-resource's manager builds the resource inside createCache(() => { …; instance.modify(positional, named); return instance }) (dist/core/class-based/manager.js). Everything consumed during that computation — including tracked state read inside modify — entangles with the cache. Dirty it and the next getValue re-runs modify. this.isDataLoaded reads the tracked originalCode and modifiedCode; this.errorMessage is tracked too. So this line entangles all three, and load writes all three.

That combination is safe here for one reason only: every path that reads them returns without writing. Data loaded → return. Error set → return. Load in flight → return. The only way through to load.perform() is with all three falsy, and every terminal state loadDiff can reach sets one of them.

Except one. loadDiff opens with if (codePatchStatus === 'applied'), which sets originalCode = null, modifiedCode = null, and returns — the one exit that leaves neither data nor an error. Reach modify in that state with unchanged inputs and it goes: read isDataLoaded (false) → read errorMessage (null) → load.perform(). Ember-concurrency starts a task instance synchronously on perform, and loadDiff's applied branch has no await before its return, so those two writes land inside the same cache computation that just read them. In a debug build that is the backtracking assertion; in production it is the cache invalidating itself on every read.

Why it doesn't fire today. I traced every consumer of this resource before writing the above. There are exactly two, both in aibot-message.gts, and both exclude applied:

  • codePatchErrorMessage returns null at its first branch when the status is applied, before it touches this.codeDiffResource.
  • The template reads codeDiffResource.isDataLoaded / isLoadingDiff only inside the {{else}} of {{#if this.isAppliedOrIgnoredCodePatch}}, and that getter is codePatchStatus === 'applied' || !isLastAssistantMessage.

So modify is never called while the status is applied, the branch at loadDiff's top is unreachable from here, and the loop cannot start. That is a real invariant, held entirely by two guards in a component two files away, with nothing in this file recording it.

The fix is one clause, and it also makes the dead branch in loadDiff honest:

Suggested change
if (!inputsChanged) {
if (this.isDataLoaded || this.errorMessage || this.loadInFlight) {
return;
}
} else {
if (!inputsChanged) {
if (
this.isDataLoaded ||
this.errorMessage ||
this.loadInFlight ||
codePatchStatus === 'applied'
) {
return;
}

With that, applied is a terminal state of modify rather than a state load is asked to reach and then abandon, and the guard stops depending on a consumer's branch structure. The codePatchStatus === 'applied' block in loadDiff can then either go or stay as documentation — but it should not be the thing standing between this and a loop.

Scope. Latent, not live — nothing in the current app reaches it. Non-blocking, but it costs four tokens to remove and the failure mode if someone later renders a loading state for an applied patch is a render loop, not a wrong pixel.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[Claude Code 🤖] Taken, in ba5f454 — and the trace is right down to the synchronous write, which is the part that makes it a loop rather than a stale read.

I confirmed the reachability argument independently before changing anything: codePatchErrorMessage returns null at its first branch on applied, before touching the resource, and the template reads isDataLoaded / isLoadingDiff only inside the {{else}} of isAppliedOrIgnoredCodePatch. Resources are pull-based, so with no consumer reading it while applied, modify doesn't run and the branch is unreachable. Latent, as you say.

Worth naming the shape precisely, because it needs two calls: the first with applied has appliedStateChanged true, so it takes the inputsChanged branch and never reads the tracked state. It's the second call — same status, inputs unchanged — that reads all three, finds them falsy because the first load nulled them, and performs. That's the read-then-write inside one computation.

I ordered the new clause first rather than last:

if (
  codePatchStatus === 'applied' ||
  this.isDataLoaded ||
  this.errorMessage ||
  this.loadInFlight
) {

|| is left-to-right, so this returns without reading tracked state at all in the applied case — the entanglement never forms, instead of forming and then being harmless. Same four tokens.

I left the applied block at the top of loadDiff. With this change it's no longer load-bearing, but it's still reached on that first call and still does the nulling, so removing it would mean moving that clearing into modify — a bigger change than the problem justifies. It now reads as what it always claimed to be: documentation for why an applied patch has no diff to show.

this.originalCode = null;
this.modifiedCode = null;
this.errorMessage = null;
}

if (!fileUrl) {
this.errorMessage = 'Missing file URL in the code block';
return;
}

if (!searchReplaceBlock) {
this.originalCode = null;
this.modifiedCode = null;
this.errorMessage = 'Missing search and replace block';
return;
}

if (
!fileOrPatchChanged &&
codePatchStatus !== 'applied' &&
this.originalCode != null &&
this.modifiedCode != null
) {
return;
}

this.load.perform();
}

get isDataLoaded() {
return this.originalCode != null && this.modifiedCode != null;
}

// True between starting a load and having something to show for it, so a
// patch whose diff has not arrived can say so rather than render as nothing.
get isLoadingDiff() {
return this.load.isRunning && !this.isDataLoaded && !this.errorMessage;
}

private load = restartableTask(async () => {
// Cancelling the task does not cancel a request already in flight, so
// without this an abandoned load runs to completion and its answer is
// merely discarded — the work still reaches the realm.
this.abortController?.abort();
let abortController = new AbortController();
this.abortController = abortController;
this.loadInFlight = true;
try {
await this.loadDiff(abortController.signal);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep cancellable work in the task body

When the inputs change after getSource resolves but while ApplySearchReplaceBlockTool.execute() is awaiting its module load, cancelling this restartable task stops only the outer task at this await; loadDiff() is a separate ordinary async operation, and the abort signal no longer stops it after the fetch. The superseded operation can therefore later overwrite originalCode, modifiedCode, or errorMessage belonging to the newer patch (and can also mutate after destruction), displaying or copying a stale diff. Keep the state-mutating awaits directly in the task or verify the controller/signal after every await before assigning resource state.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[Claude Code 🤖] Real bug, and precisely diagnosed. Fixed in f3d3f89.

You're right about the boundary: loadDiff is an ordinary async function, so cancelling the task only stops things at the await inside the task body. Past that, the abort signal reaches the fetch and nothing else — ApplySearchReplaceBlockTool.execute() awaits a module load with no knowledge of it.

Worth naming the exact hole, because it wasn't where I'd assumed. The catch after getSource already had if (signal.aborted) return, so I'd covered the fetch rejected mid-flight case. What was uncovered was the success path — a fetch that completed before the supersession, followed by an execute() that spans it. That run then resumed and assigned originalCode, modifiedCode, and errorMessage belonging to the newer patch.

Took the second of your two options — verify after every await — rather than moving the work into the task body, because it holds regardless of how ember-concurrency cancels an async task, and I didn't want the correctness of this to rest on my reading of those internals.

let result = await this.cardService.getSource(new URL(fileUrl), { signal });
if (signal.aborted) return;
originalCode = result.status === 404 ? '' : result.content;

…and the same guard on both the resolve and reject paths of execute(). One related change your comment implies: the patch is now applied against originalCode — the value this load fetched — instead of this.originalCode, which by then may belong to a different patch entirely.

The destruction case you mention falls out of the same guard, since the destructor aborts the controller.

Added a regression test that forces the ordering deterministically: two loads started with a deferred getSource, the newer one released and allowed to render its diff, then the abandoned one released afterwards. It asserts the diff still shows the newer replacement. Against the previous code the late load overwrites it.

Not verified locally — Colima is down here, so CI is exercising it.

} finally {
// Only the newest run owns the flag. A superseded run settling later must
// not report that loading has finished on behalf of the one that replaced
// it.
if (this.abortController === abortController) {
this.loadInFlight = false;
}
}
});

private async loadDiff(signal: AbortSignal) {
let { fileUrl, searchReplaceBlock, codePatchStatus } = this;
if (codePatchStatus === 'applied') {
this.originalCode = null;
Expand All @@ -95,17 +155,32 @@ export class CodeDiffResource extends Resource<CodeDiffResourceArgs> {
if (!fileUrl || !searchReplaceBlock) {
return;
}
// This runs outside the task, so cancelling the task does not stop it here:
// every await is a point where a newer load may have taken over, and the
// abort signal only reaches the fetch. Check ownership after each one
// before writing to the resource, or a superseded load resumes later and
// overwrites the state — and the diff on screen — belonging to the patch
// that replaced it.
let originalCode: string;
try {
let result = await this.cardService.getSource(new URL(fileUrl));
if (result.status === 404) {
this.originalCode = ''; // We are creating a new file, so we don't have the original code
} else {
this.originalCode = result.content;
let result = await this.cardService.getSource(new URL(fileUrl), {
signal,
});
if (signal.aborted) {
return;
}
// A 404 means we are creating a new file, so there is no original code.
originalCode = result.status === 404 ? '' : result.content;
} catch (error) {
if (signal.aborted) {
// Superseded by a newer load, or the resource went away. Neither is a
// failure to report.
return;
}
this.errorMessage = `Failed to load code from ${fileUrl}`;
return;
}
this.originalCode = originalCode;

let applySearchReplaceBlockCommand = new ApplySearchReplaceBlockTool(
this.toolService.toolContext,
Expand All @@ -114,16 +189,23 @@ export class CodeDiffResource extends Resource<CodeDiffResourceArgs> {
try {
let { resultContent: patchedCode } =
await applySearchReplaceBlockCommand.execute({
fileContent: this.originalCode,
// This load's own code, not whatever the resource holds by now.
fileContent: originalCode,
codeBlock: searchReplaceBlock,
});
if (signal.aborted) {
return;
}
this.modifiedCode = patchedCode;
} catch (error) {
this.modifiedCode = this.originalCode;
if (signal.aborted) {
return;
}
this.modifiedCode = originalCode;
this.errorMessage =
error instanceof Error ? error.message : String(error);
}
});
}
}

export function getCodeDiffResultResource(
Expand Down
6 changes: 5 additions & 1 deletion packages/host/app/services/card-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,11 +247,15 @@ export default class CardService extends Service {
return serialized;
}

async getSource(url: RealmResourceIdentifier | URL) {
async getSource(
url: RealmResourceIdentifier | URL,
opts?: { signal?: AbortSignal },
) {
let response = await this.network.authedFetch(url, {
headers: {
Accept: 'application/vnd.card+source',
},
signal: opts?.signal,
});
return {
status: response.status,
Expand Down
Loading
Loading