Skip to content

Conversation

@ethan-james
Copy link
Collaborator

@ethan-james ethan-james commented Nov 8, 2025

Fixes #3352

As discussed, getting the bounding rect of a selection range seems to work well for positioning the thought annotation without duplicating the thought in an invisible container.

TODO:

  • get the positioning right for superscripts, urls, email
  • get the positioning right for the end-of-thought faux caret
  • create a separate selection to position the start-of-thought faux caret correctly
  • get editable's bounding rect for empty thoughts with annotations

I remember that we had an issue when we were building the faux caret where the end-of-thought position was being reported inaccurately by iOS Safari. This may also impact the positioning of superscript, which would mean we need to update some snapshots. It most likely won't create a visual discrepancy in the same way that replacing the real caret with the faux caret often does.

@ethan-james ethan-james self-assigned this Nov 8, 2025
@ethan-james ethan-james marked this pull request as draft November 8, 2025 20:52
@ethan-james
Copy link
Collaborator Author

Looking at the failing tests, I see an example that I didn't think was possible.

collapsed-thought-with-url-child-1-diff

An annotation on an empty thought is going to cause trouble because there's no text to use for the selection range. The old version used the placeholder property to fill the annotation with text even if the editable only has a placeholder.

Since empty thoughts shouldn't be able to be multiline, hopefully it's possible to use the bounds of the editable itself instead of setting the selection.

@raineorshine
Copy link
Contributor

TODO:

  • get the positioning right for superscripts, urls, email
  • get the positioning right for the end-of-thought faux caret
  • create a separate selection to position the start-of-thought faux caret correctly
  • get editable's bounding rect for empty thoughts with annotations

Looks good. Thanks for keeping track of the different elements that rely on the ThoughtAnnotation. Flag anything that ends up looking intractable!

Looking at the failing tests, I see an example that I didn't think was possible.

collapsed-thought-with-url-child-1-diff An annotation on an empty thought is going to cause trouble because there's no text to use for the selection range. The old version used the `placeholder` property to fill the annotation with text even if the editable only has a placeholder.

Since this is such an edge case, I would also be okay simply hiding the url icon on empty thoughts. Seems easier than branching the measurement logic.

@ethan-james
Copy link
Collaborator Author

I've been working on getting the snapshot tests to pass by tweaking the annotation positioning, but I've come across something that might be a bit of a blocker.

Screenshot 2025-11-13 154326

Since the long link is ellipsized, the client rect of the selection range ends up being well off-screen, 1325px in this case. If you want, I could compare the x position to the viewport width and use the right edge of the editable's bounding box in that case.

@raineorshine
Copy link
Contributor

Yes, that would be great.

@ethan-james
Copy link
Collaborator Author

By tweaking the positioning carefully, I got all but one of the Puppeteer tests to pass. If you're OK with this discrepancy, then we're good to go:

multiline-font-size-13-1-diff

Something started happening where the non-Puppeteer tests started failing, mostly because range.getBoundingClientRect() was not found. I can take a look at that tomorrow and see if there are mocks in place for other selection-related functions.

It looked like faux caret positioning was pretty good by default in the new version. I wasn't sure if what I was seeing was correct, and then I decided to comment out all faux caret code and see how it looked with the native caret. It looks like Apple may have mostly fixed the caret drift! Here's a video from main:

Video.48.mov

Assuming that I did all of that correctly, it looks like it wiggles a bit but is on par with our faux caret solution, seen below for comparison:

Video.49.mov

@ethan-james
Copy link
Collaborator Author

Assuming that I did all of that correctly, it looks like it wiggles a bit but is on par with our faux caret solution, seen below for comparison:

It behaves so similarly to the faux caret that I have a sneaking suspicion that I might wake up tomorrow and realize that it is actually our faux caret, just styled slightly differently. We shall see.

@raineorshine
Copy link
Contributor

raineorshine commented Nov 19, 2025

By tweaking the positioning carefully, I got all but one of the Puppeteer tests to pass. If you're OK with this discrepancy, then we're good to go:

multiline-font-size-13-1-diff

Yes, that's fine. Thanks!

Something started happening where the non-Puppeteer tests started failing, mostly because range.getBoundingClientRect() was not found. I can take a look at that tomorrow and see if there are mocks in place for other selection-related functions.

JSDOM shims selection behavior (not mocked) so we can normally rely on window.getSelection as usual (example). But JSDOM does not do paint/layout, so getBoundingClientReact won't work (or will return all zeros). I'm surprised that it's actually not defined. Maybe needs to be mocked or added to the Range prototype?

It looked like faux caret positioning was pretty good by default in the new version. I wasn't sure if what I was seeing was correct, and then I decided to comment out all faux caret code and see how it looked with the native caret. It looks like Apple may have mostly fixed the caret drift! Here's a video from main:

Amazing! I love deleting code! 😈

Based on some initial tests, I still see some discrepancies with the native behavior sadly, though it has improved greatly. If you could create a PR with the FauxCaret removed/disabled, then I will post my findings there. Even if we don't end up ripping it out yet, we can use the PR for documentation.

@ethan-james ethan-james marked this pull request as ready for review November 20, 2025 22:13
@ethan-james
Copy link
Collaborator Author

I fixed the positioning of the start-of-thought faux caret, and I think this is ready for review.


// We're trying to get rid of contentWidth as part of #3369, but currently it's the easiest reactive proxy for a viewport resize event.
const contentWidth = viewportStore.useSelector(state => state.contentWidth)
const cursor = useSelector(state => state.cursor)
Copy link
Contributor

Choose a reason for hiding this comment

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

I want to check in about the potential performance implications of this. This would cause every visible ThoughtAnnotation instance to re-render whenever the cursor changes. Does this differe from main? Can this be narrowed down?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yes, thanks, I think I can re-use isEditing to re-render the annotations when a URL ellipsizes instead of doing it every time the cursor changes.

Comment on lines 92 to 95
<span
className={css(
{
visibility: 'hidden',
Copy link
Contributor

@raineorshine raineorshine Nov 22, 2025

Choose a reason for hiding this comment

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

I believe the goal would be to remove the hidden <span> entirely since it is no longer used to position the annotations. Is this possible, or are there other things that depend on it?

If it is still required, maybe we can put together a rough plan of what it would take to remove it.

I'm also open to doing this in another PR if having an extra step in the commit history after merge would be helpful.

Copy link
Collaborator Author

@ethan-james ethan-james Nov 22, 2025

Choose a reason for hiding this comment

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

If I remove the span, then it messes up the superscripts:

color-theme-superscript-on-light-theme-1-diff

I thought it might be as simple as adding an explicit height to the container, such as height: multiline ? '1.25em' : '2em'. That did fix the superscripts, but it caused other layout problems:

color-theme-superscript-on-light-theme-1-diff

I went ahead and removed the textMarkup and placeholder props so that it will only ever render '&ZeroWidthSpace;'. In terms of removing the span entirely, I think that there are two moving parts:

  1. Setting the height of the container correctly so that it leaves room for annotations without pushing lower thoughts out of place
  2. Figuring out if anything breaks now that we are relying on typographic CSS such as verticalAlign: 'text-top' while no longer including any text in the annotation.

I wouldn't mind doing this in a separate PR so that we can get this one merged, but it's up to you.

@raineorshine
Copy link
Contributor

Thanks. I'm bringing the discussion here as there seems to be more at stake than originally anticipated.

If I remove the span, then it messes up the superscripts:

color-theme-superscript-on-light-theme-1-diff

I thought it might be as simple as adding an explicit height to the container, such as height: multiline ? '1.25em' : '2em'. That did fix the superscripts, but it caused other layout problems:

color-theme-superscript-on-light-theme-1-diff

I went ahead and removed the textMarkup and placeholder props so that it will only ever render '&ZeroWidthSpace;'. In terms of removing the span entirely, I think that there are two moving parts:

  1. Setting the height of the container correctly so that it leaves room for annotations without pushing lower thoughts out of place

  2. Figuring out if anything breaks now that we are relying on typographic CSS such as verticalAlign: 'text-top' while no longer including any text in the annotation.

This is giving me some doubts about the current solution and whether I want to merge. The main point of this task was to remove the dependency on the the invisible placeholder element and reduce complexity. Now we have a hybrid solution which relies partly on DOM measurement and partly on the placeholder, which seems to me less good than either pure approach.

We began with the theory that the superscript can be rendered a fixed number of em up from the bottom-right corner of the editable, without any placeholders. Is this not true? Maybe better understanding the path to true decoupling from the placeholder will help us get to the right solution.

@ethan-james
Copy link
Collaborator Author

Actually, I realized that I can use multiline as a dependency to trigger an async re-positioning for multiline thoughts, eliminating the need for a timeout if the thought isn't multiline. Seems to work OK so far!

Copy link
Contributor

@raineorshine raineorshine left a comment

Choose a reason for hiding this comment

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

I can confirm that Edited thought superscript position incorrect on first paint
is fixed.

I'm still seeing the broken behavior after toggle context view: Deactivated context view superscript position incorrect on first paint.

The test case from #3357 (comment) still looks solid.

@ethan-james
Copy link
Collaborator Author

I can confirm that Edited thought superscript position incorrect on first paint is fixed.

I'm still seeing the broken behavior after toggle context view: Deactivated context view superscript position incorrect on first paint.

The test case from #3357 (comment) still looks solid.

It seems like when virtualized thoughts re-enter the viewport after the context view thoughts leave, they come back with the annotation in the wrong position. I'm not sure why that would be the case, but hiding the annotation until positioning occurs seems to reliably fix the issue.

@raineorshine
Copy link
Contributor

@fbmcipher Could you do a performance comparison between this and main? It removes some components from the React hierarchy but adds some getBoundingClientRect calls. The intention is for this PR to reduce complexity and improve performance. I want to make sure the actual behavior matches our expectations.

I recommend a test case that expands a context of 50–100 thoughts with superscripts.

@fbmcipher
Copy link
Collaborator

@raineorshine Sure thing – I'll run a couple quick tests and post my results here tomorrow.

Copy link
Contributor

@raineorshine raineorshine left a comment

Choose a reason for hiding this comment

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

I can confirm that Edited thought superscript position incorrect on first paint is fixed.
I'm still seeing the broken behavior after toggle context view: Deactivated context view superscript position incorrect on first paint.
The test case from #3357 (comment) still looks solid.

It seems like when virtualized thoughts re-enter the viewport after the context view thoughts leave, they come back with the annotation in the wrong position. I'm not sure why that would be the case, but hiding the annotation until positioning occurs seems to reliably fix the issue.

Thanks! That's better.

A few things I found:

  1. It looks like some superscripts are being hidden that don't need to be hidden. It should only affect children that are being animated in/out, not the cursor thought, siblings, or ancestors.
  2. Could you add a fade in to the superscripts? I'd like to smooth out the transition so it's a little less abrupt.
  3. I found one other case that needs to be accounted for. This reminds me of all the cases that needed to be identified for useMultiline. Anything that changes the position or width of the thought can potentially invalidate the superscript position. Maybe we can borrow from useMultiline and even abstract out the shared elements?

It's too bad that ResizeObserver is too slow to use for every visible thought, as the ad hoc React dependencies are pretty error prone.

Steps to Reproduce

- x
  - =children
    - =pin
      - true
  - a
    - This is a longer thought that will extend onto two lines if I keep typing and it may have a superscript at any point.
- y
  - a
    - This is a longer thought that will extend onto two lines if I keep typing and it may have a superscript at any point.
  1. Set the cursor on x/a.
  2. Activate Table View

Current Behavior

The superscript of the long thought has the wrong position.

Brave Browser 2025-12-30 12 23 59

Expected Behavior

The superscript position should be updated correctly.

Brave Browser 2025-12-30 12 24 06

@ethan-james
Copy link
Collaborator Author

  1. It looks like some superscripts are being hidden that don't need to be hidden. It should only affect children that are being animated in/out, not the cursor thought, siblings, or ancestors.

It seems like the context animation is applied to all thoughts, not just ones that are entering or leaving the context view. Probably because it's tough to determine which thoughts are animating out and leaving the DOM. Do you think it's worthwhile to try to narrow that down so that the animation is not applied to thoughts that are higher up than the cursor thought?

@fbmcipher
Copy link
Collaborator

@raineorshine Please find a description of the performance test I ran, along with the results below.

Steps to Reproduce

Expand a context of 50-100 thoughts with superscripts.

  1. Import ai-generated-thoughtspace-3000-thoughts.txt.
  2. Set the cursor to Architecture.
  3. Activate Context View.

Note 1: This was my interpretation of the test based on the instructions given and the fact that the positionAnnotation callback executes after the Context View animation completes. Let me know if I got it wrong.

Note 2: Due to the slow Context View animation, performance is abnormally slow in both build, and in such a way that fluctuates between runs. However, because the animation behaviour is unchanged between main and this PR, it should not affect our comparisons. I will mitigate this variance in the test method by running tests multiple times and averaging.

Test Case

Screenshot 2025-12-31 at 01 35 11

Method

  • Measure the time from tapping the Context View icon to the point where layout fully settles, using frame-by-frame timings and snapshots taken by the Chrome Performance Profiler.

  • Repeat this measurement 10 times and compute the average.

Note: In the interest of time efficiency, I didn't perform a full stack trace analysis this time around – instead using time as a quick "smell test" for the overall performance impact of this PR. I do have stack traces saved and can follow up with deeper analysis if necessary.

Results

Run time in main (ms) time in this PR (ms)
1 3748 3163
2 4184 3821
3 5034 3840
4 3810 3103
5 3854 3773
6 3691 3669
7 4260 3733
8 3891 3755
9 3774 3318
10 3837 3702
Sum 40083 35877
Mean (incl. outliers) 4008 3587
Mean (excl. outliers) 3909 3587

Observations

  • Note the fluctuations in the time measurement from run to run. A cursory review in the profiler indicates the fluctuation is caused by the slow Context View animation.
  • One measurement on main was significantly higher than the rest, which I flagged as an outlier. It's interesting to see that no comparable outlier was seen in the results from this PR.
  • Regardless of whether we consider outliers in our average, this PR shows a consistently measurable speed improvement in the time taken for the layout to settle after toggling Context View.

My conclusion

Though there is admittedly a weakness in this test (the slow Context View animation creates undesirable fluctuation in our results), even excluding the noticeable outlier the time taken for the layout to settle after triggering Context View is measurably faster in this PR than in main.

This provides reasonable confidence that the PR does improve performance in practice.

@ethan-james
Copy link
Collaborator Author

  1. It looks like some superscripts are being hidden that don't need to be hidden. It should only affect children that are being animated in/out, not the cursor thought, siblings, or ancestors.

It seems like the context animation is applied to all thoughts, not just ones that are entering or leaving the context view. Probably because it's tough to determine which thoughts are animating out and leaving the DOM. Do you think it's worthwhile to try to narrow that down so that the animation is not applied to thoughts that are higher up than the cursor thought?

It seems like using belowCursor works pretty well to narrow down which annotations are recalculated, apart from some unpleasant prop drilling. However, there are still thoughts down below that shouldn't recalculate, as you can see if you start from the repro in #3357 (comment) and toggle the context view on c instead of b.

Recording.2025-12-31.145250.mp4

@ethan-james
Copy link
Collaborator Author

It seems like using belowCursor works pretty well to narrow down which annotations are recalculated, apart from some unpleasant prop drilling. However, there are still thoughts down below that shouldn't recalculate, as you can see if you start from the repro in #3357 (comment) and toggle the context view on c instead of b.

I think it should be all set now. Instead of tracking belowCursor, I added inTransition to track whether an animation is in progress and not re-position the annotation until after the animation ends.

Copy link
Contributor

@raineorshine raineorshine left a comment

Choose a reason for hiding this comment

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

After playing with this a bit, I unfortunately have come to the conclusion that rendering the superscripts after a delay is too disruptive to the UX. This is true whether the superscripts snap in or fade in smoothly.

Theoretically it's possible to measure the bounding box synchronously in useLayoutEffect and render the superscript before the first paint, though it will have a performance impact. I'd love to get your input on how viable this might be before we spend additional time.

I did another review below, but they are moot points if we're unable to efficiently render the superscript synchronously.

Thanks so much for your work.


I'm still seeing the issue described at the end of this comment. Are you able to reproduce? #3357 (review)

Note 1: This was my interpretation of the test based on the instructions given and the fact that the positionAnnotation callback executes after the Context View animation completes. Let me know if I got it wrong.

I don't think this changes the results necessarily, but I meant a simple cursor navigation in normal view that expands a context. Activating the Context View has its own performance issues, so I would avoid that and test superscript rendering without the context view animation. Sorry for the confusion!

  • Measure the time from tapping the Context View icon to the point where layout fully settles, using frame-by-frame timings and snapshots taken by the Chrome Performance Profiler.

I'm not sure that measuring the time in between the start and finish really measures performance. That's just measuring animation duration, which should generally be constant. If performance drops, the browser will drop frames without changing the animation duration. Only if the CPU is thrashing will the animation actually slow down. I think we need to be measuring either the CPU usage in ms or the average frame rate, ideally without the confounding variable of the Context View animation.

@ethan-james
Copy link
Collaborator Author

Thanks so much for your work.

You're welcome, it was a worthwhile effort nonetheless.

Theoretically it's possible to measure the bounding box synchronously in useLayoutEffect and render the superscript before the first paint, though it will have a performance impact. I'd love to get your input on how viable this might be before we spend additional time.

I can test fairly quickly tomorrow, but I have a few concerns:

  1. bounding box won't be the same as the end of the text in a multiline thought (you probably thought of that)
  2. not sure why the bounding box would be in the correct position when the selection range is not
  3. maybe it will be in the correct position since the animations are controlled by transform and the GPU position is probably independent of the DOM position, but that should also apply to the selection range

@raineorshine
Copy link
Contributor

Oops, I meant selection range, not bounding box! The question is whether it's possible or not to use the same technique that we are using here, but before the first paint.

@ethan-james
Copy link
Collaborator Author

Oops, I meant selection range, not bounding box! The question is whether it's possible or not to use the same technique that we are using here, but before the first paint.

I updated it to useLayoutEffect, but it seems to look about the same.

Recording.2026-01-02.104150.mp4

I verified that changing the animation duration to 0 fixes it, as does removing the transform from the animation.

It also seems like the bounding box returned by editableRef.current.getBoundingClientRect() is also moving.

Recording.2026-01-02.105041.mp4

In this case, when I set the animation duration to 0, it jumps from a different initial position to the new position. If I remove transform from the animation, then it starts in its final position.

@raineorshine
Copy link
Contributor

It seems like there are too many issues for this to be a viable approach. This was a costly experiment, but I guess we couldn't have known until we tried. Thank you for your effort.

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.

ThoughtAnnotation: Calculate position instead of using invisible placeholder

4 participants