Skip to content

Topic link autocomplete - #2137

Open
sm-sayedi wants to merge 18 commits into
zulip:mainfrom
sm-sayedi:124-topic-link-autocomplete
Open

sm-sayedi wants to merge 18 commits into
zulip:mainfrom
sm-sayedi:124-topic-link-autocomplete

Conversation

@sm-sayedi

@sm-sayedi sm-sayedi commented Feb 12, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes: #124

A follow-up to #1902.

Design discussion: #mobile-design > topic link autocomplete

Screenshots
Empty query (light) Empty query (dark)
Screenshot 2026-08-18 at 10 43 52 AM Screenshot 2026-08-18 at 10 46 29 AM
Empty query (shortcut syntax) New topic
Screenshot 2026-08-18 at 10 49 55 AM Screenshot 2026-08-18 at 10 59 39 AM
Long names Option tapped
Screenshot 2026-08-18 at 10 48 58 AM Screenshot 2026-08-18 at 10 52 21 AM
Screencast
Topic.link.autocomplete.mov

@sm-sayedi
sm-sayedi force-pushed the 124-topic-link-autocomplete branch 2 times, most recently from 3ea07c0 to c98acb1 Compare February 17, 2026 18:49
@sm-sayedi
sm-sayedi force-pushed the 124-topic-link-autocomplete branch from c98acb1 to c604d9e Compare February 17, 2026 19:28
@sm-sayedi
sm-sayedi marked this pull request as ready for review February 17, 2026 19:28
@sm-sayedi sm-sayedi added the maintainer review PR ready for review by Zulip maintainers label Feb 17, 2026
@sm-sayedi
sm-sayedi requested a review from chrisbobbe February 17, 2026 19:29
@alya

alya commented Mar 6, 2026 •

Copy link
Copy Markdown
Collaborator

Hmm, I think the multi-line channel name doesn't look great (topic is not too bad). On web, we abbreviate both to one line -- maybe we should do the same here?

Screenshot 2026-03-05 at 23 56 27@2x

@sm-sayedi
sm-sayedi force-pushed the 124-topic-link-autocomplete branch 2 times, most recently from 06f335c to 07b4f45 Compare March 6, 2026 16:33
@sm-sayedi

Copy link
Copy Markdown
Collaborator Author

Thank you @alya for the suggestion. Made it one liner.

Before After
Screenshot 2026-03-06 at 8 50 11 PM Screenshot 2026-03-06 at 9 05 58 PM

@sm-sayedi
sm-sayedi force-pushed the 124-topic-link-autocomplete branch from 07b4f45 to d9a3b48 Compare March 12, 2026 16:24

@chrisbobbe chrisbobbe left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks! Sorry again that I've let this sit. I noticed the symptom of #226 when testing this manually, and considered it a blocker—the topic items don't pop up immediately when you tap a channel, which they definitely should. Anyway, I've sent flutter/flutter#183705 upstream for that, and hopefully it'll land soon, giving us new API that we'll use to fix the bug. 🙂

Comments below.

@@ -1095,7 +1095,7 @@ class AutocompleteDataCache {

List<String> normalizedNameWordsForChannel(ZulipStream channel) {
return _normalizedNameWordsByChannel[channel.streamId]
?? normalizedNameForChannel(channel).split(' ');
??= normalizedNameForChannel(channel).split(' ');

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Indeed, thanks for catching this!

Comment thread lib/model/autocomplete.dart Outdated
Comment on lines 1272 to 1275
List<String> normalizedNameWordsForChannelTopic(int channelId, String topic) {
return (_normalizedNameWordsByChannelTopic[channelId] ?? {})[topic]
??= normalizedNameForChannelTopic(channelId, topic).split(' ');
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
List<String> normalizedNameWordsForChannelTopic(int channelId, String topic) {
return (_normalizedNameWordsByChannelTopic[channelId] ?? {})[topic]
??= normalizedNameForChannelTopic(channelId, topic).split(' ');
}
List<String> normalizedNameWordsForChannelTopic(int channelId, String topic) {
return (_normalizedNameWordsByChannelTopic[channelId] ??= {})[topic]
??= normalizedNameForChannelTopic(channelId, topic).split(' ');
}

Right?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Indeed, thanks.

Comment thread lib/model/autocomplete.dart Outdated
Comment on lines +1670 to +1673
final queryTopicResult = query.testQueryTopic(TopicName(query.raw),
matchedTopics: unsorted.whereType<TopicLinkAutocompleteTopicResult>()
.map((r) => r.topic));
if (queryTopicResult != null) unsorted.add(queryTopicResult);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

It should be possible to check whether a topic exists in constant time; let's try to avoid a linear scan through the filtered topics (unsorted) on every keystroke just for that. How about calling store.topics.latestMessageInTopic, where .latestMessageInTopic is a new int?-returning method you add to Topics, which checks _latestMessageIdsByChannelTopic.

Comment on lines +1675 to +1676
return bucketSort(unsorted, (r) => r.rank,
numBuckets: TopicLinkAutocompleteQuery._numResultRanks);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This bucket-sort step (here and in other autocompletes) is really handling two things:
a) Query relevance i.e. match quality
b) Everything we want to take precedence over query relevance

For example, in @-mention autocomplete, we rank user-group results below user results even when the user-group result would be a better match for the query.

Here, the ranking of TopicLinkAutocompleteChannelResult is clearly (b): its position relative to other list items has nothing to do with how well it matches the query. In fact we're not really "matching" this result to the query at all—we're just implementing the product choice that it should be present, at the top, when the query is empty, and absent otherwise.

How about hard-coding the rank of TopicLinkAutocompleteChannelResult to 0? (Also adding some dartdoc so its meaning is clearer):

+/// A result for just a channel link, after all, not any topic.
+///
+/// Offered at the top of the list when the topic query is empty.
 class TopicLinkAutocompleteChannelResult extends TopicLinkAutocompleteResult {
-  TopicLinkAutocompleteChannelResult({required this.channelId, required this.rank});
+  TopicLinkAutocompleteChannelResult({required this.channelId});
 
   @override
   final int channelId;
 
+  /// This should always come first in the list of options.
   @override
-  final int rank;
+  int get rank => 0;
 }

Then follow the resulting analyzer errors, on paths that should make sense intuitively, which I think means: TopicLinkAutocompleteQuery.testChannel disappears, and its caller is replaced with something like

    if (query.raw.isEmpty) {
      unsorted.add(TopicLinkAutocompleteChannelResult(channelId: channelId));
    }

and so on; also update dartdocs appropriately, e.g.:

  • Dartdoc on TopicLinkAutocompleteResult.rank moves to subclass TopicLinkAutocompleteTopicResult.rank, because it's only about topics
  • _rankChannelResult disappears
  • _rankTopicResult's dartdoc is updated, e.g.:
   /// A measure of a topic result's quality in the context of the query,
-  /// from 0 (best) to one less than [_numResultRanks].
+  /// from 1 (best) to one less than [_numResultRanks].
   ///
-  /// See also [_rankChannelResult].
+  /// (Rank 0 is reserved for [TopicLinkAutocompleteChannelResult].)
   static int _rankTopicResult({
  • and _numResultRanks:
-  /// The number of possible values returned by [_rankResult].
+  /// The number of possible values returned by [_rankTopicResult],
+  /// plus one for the [TopicLinkAutocompleteChannelResult].
   static const _numResultRanks = 5;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Interesting points. Brought the changes except for TopicLinkAutocompleteQuery.testChannel still in place. I think it and the other method (testQueryTopic, now testNewTopic) make it easier for the test code to check the rankings. Please let me know if it needs changing! 🙂

Comment thread lib/model/autocomplete.dart Outdated
Comment on lines +1718 to +1728
TopicLinkAutocompleteTopicResult? testQueryTopic(TopicName queryTopic, {
required Iterable<TopicName> matchedTopics,
}) {
assert(raw == queryTopic.apiName);
if (raw.isEmpty) return null;
final queryTopicExists = matchedTopics.any((t) => t.isSameAs(queryTopic));
if (queryTopicExists) return null;
return TopicLinkAutocompleteTopicResult(
channelId: channelId, topic: queryTopic, isNew: true,
rank: TopicLinkAutocompleteQuery._rankTopicResult(isNewTopic: true));
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Like the testChannel case, I think this work can be inlined in TopicLinkAutocompleteView.computeResults, for similar reasons. (Also see my previous comment about checking for topic existence.)

I would also probably make a new subclass of TopicLinkAutocompleteResult just for the new-topic item, with rank hard-coded to 1 (similarly to the channel result's hardcoding to 0), making sure to update TopicLinkAutocompleteResult's dartdoc which refers to two subclasses.

Comment thread lib/model/autocomplete.dart Outdated
}
}
} else if (charAtPos == '[') {
final channelFallbackLinkMatch = _channelFallbackLinkWithTopicDelimiterRegex.matchAsPrefix(textUntilCursor, pos);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

autocomplete: Identify when the user intends a topic link autocomplete

Would you separate the "fallback link" edge-case handling into its own commit? There's a lot going on in this commit, and I'd like to focus on the common case first.

Comment thread lib/model/autocomplete.dart Outdated
Comment on lines +86 to +88
value = value.replaced(
TextRange(start: pos, end: value.selection.end),
channelLink(channel, isComplete: false, store: store));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

            // Replace "#**…** >" with "#**…>" to trigger the topic autocomplete
            // interaction for the channel.

This seems helpful, but it also seems like its own "coherent idea" that should go in its own commit, for ease of review.

Also: I wouldn't expect side effects (setting value) in the autocompleteIntent method. I think of it as basically a getter, except by convention we write getters as methods when they involve expensive computation, basically as a hint to potential callers that they should think about performance.

Can we change value at a different layer? From Flutter docs, I think a custom TextInputFormatter looks like the right thing for this. It would need to do its own scan backward through the text, but that can be skipped except when there's a ">" to the left of the cursor.

Then this autocompleteIntent pseudo-getter would stay focused on the job of detecting an intent, making it much easier to reason about.

final store = PerAccountStoreWidget.of(context);
final channel = store.streams[option.channelId];

if (channel == null) return SizedBox.shrink();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can this happen?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Yeah, in cases like ChannelDeleteEvent.

Comment thread lib/widgets/autocomplete.dart Outdated
Comment on lines +453 to +454
icon = Icon(iconDataForStream(channel), size: _iconSize,
color: colorSwatchFor(context, store.subscriptions[channel.streamId]));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

nit: indentation

Comment thread lib/widgets/autocomplete.dart Outdated
Comment on lines +487 to +488
]))),
);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

nit: put parens on same line

@sm-sayedi

Copy link
Copy Markdown
Collaborator Author

Thanks @chrisbobbe for the review, and sorry for the late revision. Pushed new changes, PTAL.

@sm-sayedi
sm-sayedi requested a review from chrisbobbe April 7, 2026 18:55
@sm-sayedi
sm-sayedi force-pushed the 124-topic-link-autocomplete branch from 442fd7e to 0347af8 Compare April 7, 2026 19:37
@sm-sayedi
sm-sayedi force-pushed the 124-topic-link-autocomplete branch from 0347af8 to e4dfa12 Compare April 22, 2026 04:53
@sm-sayedi

Copy link
Copy Markdown
Collaborator Author

(rebased atop main, resolving conflicts.)

@chrisbobbe chrisbobbe left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Coming back to this, I see I had a partial review that I forgot to send—apologies, here it is.

Comment thread lib/model/autocomplete.dart Outdated
Comment on lines +1157 to +1165
void invalidateChannelTopic(int channelId, {String? topic}) {
if (topic == null) {
_normalizedNamesByChannelTopic.remove(channelId);
_normalizedNameWordsByChannelTopic.remove(channelId);
} else {
_normalizedNamesByChannelTopic[channelId]?.remove(topic);
_normalizedNameWordsByChannelTopic[channelId]?.remove(topic);
}
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Hmm, I thought I already said this but I'm not finding it for some reason:

Let's skip the complexity of nesting topics under channel IDs in these caches. We still want to memoize the work of producing these normalized forms. But the channel ID is irrelevant to that work: we can just have Maps keyed by topic display name, right:

  • _normalizedTopicDisplayNames
  • _normalizedTopicDisplayNameWords

And we can skip cache invalidation too; there's no stateful input to the computation of "some topic" and ["some", "topic"] from "söme ToPiC".

Comment thread lib/model/autocomplete.dart Outdated

/// An [AutocompleteView] for a #channel>topic autocomplete interaction,
/// an example of a [ComposeAutocompleteView].
class TopicLinkAutocompleteView extends AutocompleteView<TopicLinkAutocompleteQuery, TopicLinkAutocompleteResult> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

nit: double space before { at end of line

Comment thread test/model/autocomplete_test.dart Outdated
..topic.equals(topic);
}

test('misc', () async {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

How about "smoke" instead of "misc" (as in "smoke test")

]);
});

test('updates results when topics are loaded', () => awaitFakeAsync((async) async {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The "misc" test also checks that results are loaded, but I guess this test is different because it also checks the intermediate state after TopicLinkAutocompleteView.init and before the results are loaded. With the amount of code in each test, I'd prefer to just drop one of these tests; or could alternatively deduplicate with helper functions.

(edit: but see a later comment where I suggest a change that would make the intermediate state less interesting, by not showing the "channel result" or "new topic result")

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Dropped the first one.

Comment thread lib/model/compose.dart Outdated
String channelLink(ZulipStream channel, {required PerAccountStore store}) {
///
/// Set [pendingTopicAutocomplete] to `true` for an incomplete syntax:
/// #**…> or [#…](#…)>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

nit: The two #s in the "fallback" form confused me at first; then I realized they had different meanings. How about spelling out more of the abbreviation, like [#…](#narrow…)>

Comment thread lib/model/autocomplete.dart Outdated
Comment on lines 313 to 314
// otherwise '#**channel**' (which is a completed channel link syntax) and
// any text followed by that will always match.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This existing comment looks wrong; would you fix it? It should say "and any text following that" (or "after that"), right? (Similarly in _topicLinkIntentRegex where you copy-pasted the error.)

Comment thread lib/model/autocomplete.dart Outdated
// TODO: match the server constraints
const nameCharExclusions = r'\r\n';
// TODO: incorporate the server constraints
const nameCharExclusions = r'>\r\n';

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

">" is valid in a channel name, isn't it?, at least according to the comment here.

There's tension, though, because of this character's special role in topic-link autocomplete. Rather than add > to this list and explain why it's there, though (and you haven't explained it in this revision actually 🙂), can we instead cause topic-link autocomplete to "win" by switching the order of checking for matches of _topicLinkIntentRegex and _channelLinkIntentRegex? So that we check _topicLinkIntentRegex first.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

We could remove > from nameCharExclusions and reorder checking for matches, but it would mismatch some other syntaxes. For example, ‍‍‍‍‍‍[#…](#narrow…)>topic, which is a fallback topic link intent with topic as the query string, would be detected as a channel link intent with narrow…)>topic as the query string.

How about renaming nameCharExclusions to something that describes the purpose more accurately; how about queryCharExclusions?

Comment on lines 22 to 38
int get _maxLookbackForAutocompleteIntent {
return 1 // intent character, e.g. "#"
+ 2 // some optional characters e.g., "_" for silent mention or "**"

// Per the API doc, maxChannelNameLength is in Unicode code points.
// Longest autocomplete syntax is the fallback topic link intent (as of 2026-02):
// [#escapedChannelName](#narrow/channel/channelId-slugifiedChannelName)>topicName
return 2 // [#
// Largest length of an escaped channel name (see `compose.escapeChannelTopicAvoidedChars`).
+ 5 * store.maxChannelNameLength
+ 18 // ](#narrow/channel/
+ 19 // largest channel ID (9223372036854775807 — largest int) length
+ 1 // hyphen character (-)
// Largest length of a slugified channel name (see `internal_link.narrowLinkFragment`).
+ 3 * store.maxChannelNameLength
+ 2 // )>
// Per the API doc, maxTopicLength is in Unicode code points.
// We walk the string by UTF-16 code units, and there might be one or two
// of those encoding each Unicode code point.
+ 2 * store.maxChannelNameLength;
+ 2 * store.maxTopicLength;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I see; the point is that we want to show the (probably single) autocomplete option when you backspace through the end of the topic-link syntax, and in particular we want do that even when the topic-link syntax is extremely long.

From some profiling data, it looks like this is unlikely to cause perf problems, at least—#mobile-team > profilePrint test on autocomplete with different maxlookback @ 💬—but I still think it might be best to hard-code a value, to save the dev overhead of maintaining this complicated expression. Happy to wait for Greg's thoughts in integration review though.

Comment thread lib/model/autocomplete.dart Outdated
if (topicIntentMatch == null) continue;
final channelId = topicIntentMatch[1] != null
? _channelIdFromNarrow
: store.streamsByName[topicIntentMatch[2]]?.streamId;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can we avoid adding this dependency on external state (channels) and handle the channel lookup at a different layer than this string-processing step?

I'm thinking:

  • TopicLinkAutocompleteQuery stores the channel name as String?, with null for the shorthand #>topic case (which I learned about just today from reading this PR!)
  • TopicLinkAutocompleteView receives the narrow from the widget code and stores that, just like ChannelLinkAutocompleteView and other AutocompleteViews do.
  • TopicLinkAutocompleteView looks up the channel from the channel name on the query or from the narrow

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The current revision has this refactoring, but revisiting this, I am somewhat skeptical about it. 🙂

  • I think it adds more complexity to the code, specifically to the TopicLinkAutocompleteView class, where we should check channelId != null in several places.
  • I think allowing channelId to be null inside the TopicLinkAutocompleteView class makes it a little hard to reason about, or at least raises the question about "how could it be null?", for which the main answer is that the channel doesn't exist! So if the channel doesn't exist, surely we don't want to provide the autocomplete for its topics. So wouldn't it be better to avoid triggering an autocomplete intent (thus creating a TopicLinkAutocompleteView object) in the first place?
  • There are already some dependencies on external states (eg, store.maxChannelNameLength and store.maxTopicLength) inside the autocompleteIntent method. I am not suggesting that this is a legit reason to add more too, but I assume it would be a good trade-off for more code clarity.

Comment thread lib/model/autocomplete.dart Outdated
Comment on lines +1661 to +1666
Future<void> _fetch() async {
// TODO: handle fetch failure
// TODO(#2154): do not fetch topics for "only general chat" channel
_topics = (await store.topics.getChannelTopics(channelId)).map((e) => e.name);
return _startSearch();
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This _startSearch() will always come after a _startSearch() that's doomed to find zero results because it happens before we get the topics list. I mean the one in the super constructor:

  /// Construct a view-model for an autocomplete interaction,
  /// and begin the search for the initial query.
  AutocompleteView({required this.store, required this._query}) {
    _startSearch();
    store.autocompleteViewManager.registerAutocomplete(this);
  }

Can we do just one _startSearch() at the beginning instead of two? How about instead making the await store.topics.getChannelTopics call at the top of computeResults? It's true that computeResults is a hot path (called on each keystroke of the query) but getChannelTopics is intentionally designed to not fetch redundantly for the same channel.

I guess this means we'd delay showing the "channel result" or "new topic result" until the topics are fetched. That seems fine to me, and in fact more correct for the latter (because we can't know if the topic is "new" until we know the existing topics).

@sm-sayedi sm-sayedi May 3, 2026 •

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Yeah, we could do that, but I wonder if it would make the user wait for the topics to be fetched, even if they only wanted to generate a channel link. Started a new discussion on CZO.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

As decided in the discussion linked above, brought the new changes.

@sm-sayedi
sm-sayedi force-pushed the 124-topic-link-autocomplete branch 3 times, most recently from 680494c to ee7bb81 Compare May 8, 2026 06:33
@sm-sayedi

sm-sayedi commented May 8, 2026 •

Copy link
Copy Markdown
Collaborator Author

Thanks @chrisbobbe for the review. Pushed new changes, PTAL. Also added a few replies in the thread above: #2137 (comment) and #2137 (comment).

@sm-sayedi
sm-sayedi requested a review from chrisbobbe August 27, 2026 16:45
chrisbobbe added a commit to chrisbobbe/zulip-flutter that referenced this pull request Aug 27, 2026
Before this commit, `shouldStop` detected a stale search by checking
whether the query had changed. That cannot tell apart two searches
for an equal query, which the widgets code does start: it sets
`query` on each change to the text field, including changes that
leave the query the same. (For example, any cursor movement in the
compose box's topic input.)

Both searches would then run to completion and notify twice; the
fresh results win only because the newer search happens to be
scheduled last. Soon, changes to the data being searched will also
start a same-query search (zulip#2137, zulip#2326: e.g. on any change to
`Topics`), and we don't want the results to depend on which of two
searches over different data finishes first.

So count searches with a generation number instead: capture it when
a search starts, and stop when it has moved on. (This follows the
pattern of `MessageListView.generation` for invalidating stale
fetches.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
chrisbobbe added a commit to chrisbobbe/zulip-flutter that referenced this pull request Aug 27, 2026
Before this commit, `shouldStop` detected a stale search by checking
whether the query had changed. That cannot tell apart two searches
for an equal query, which the widget code does start: it sets
`query` on each change to the text field, including changes that
leave the query the same. (For example, any cursor movement in the
compose box's topic input.)

Both searches then run to completion and notify twice. That's
harmless today beyond the wasted work, since both see the same data.
But soon, changes to the data being searched will also start a
same-query search (zulip#2137, zulip#2326: e.g. on any change to `Topics`),
and then we don't want the results to depend on which of two
searches over different data finishes first.

So count searches with a generation number instead: capture it when
a search starts, and stop when it has moved on. (This follows the
pattern of `MessageListView.generation` for invalidating stale
fetches.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
sm-sayedi added a commit to sm-sayedi/zulip-flutter that referenced this pull request Aug 28, 2026
In the next commit(s), this will become handy for efficiently
determining if a topic exists, in constant time.

Prompted by zulip#2137 (comment).
@sm-sayedi
sm-sayedi force-pushed the 124-topic-link-autocomplete branch from ce1dca1 to 2225301 Compare August 28, 2026 12:09
sm-sayedi added a commit to sm-sayedi/zulip-flutter that referenced this pull request Aug 28, 2026
In the next commit(s), this will become handy for efficiently
determining if a topic exists, in constant time.

Prompted by zulip#2137 (comment).
@sm-sayedi
sm-sayedi force-pushed the 124-topic-link-autocomplete branch from 2225301 to 26c98e5 Compare August 28, 2026 15:58
sm-sayedi pushed a commit to sm-sayedi/zulip-flutter that referenced this pull request Sep 16, 2026
Before this commit, `shouldStop` detected a stale search by checking
whether the query had changed. That cannot tell apart two searches
for an equal query, which the widget code does start: it sets
`query` on each change to the text field, including changes that
leave the query the same. (For example, any cursor movement in the
compose box's topic input.)

Both searches then run to completion and notify twice. That's
harmless today beyond the wasted work, since both see the same data.
But soon, changes to the data being searched will also start a
same-query search (zulip#2137, zulip#2326: e.g. on any change to `Topics`),
and then we don't want the results to depend on which of two
searches over different data finishes first.

So count searches with a generation number instead: capture it when
a search starts, and stop when it has moved on. (This follows the
pattern of `MessageListView.generation` for invalidating stale
fetches.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
sm-sayedi added a commit to sm-sayedi/zulip-flutter that referenced this pull request Sep 16, 2026
In the next commit(s), this will become handy for efficiently
determining if a topic exists, in constant time.

Prompted by zulip#2137 (comment).
@sm-sayedi
sm-sayedi force-pushed the 124-topic-link-autocomplete branch from 26c98e5 to 4842f28 Compare September 16, 2026 17:11
chrisbobbe added a commit to chrisbobbe/zulip-flutter that referenced this pull request Oct 5, 2026
Before this commit, `shouldStop` detected a stale search by checking
whether the query had changed. That cannot tell apart two searches
for an equal query, which the widget code does start: it sets
`query` on each change to the text field, including changes that
leave the query the same. (For example, any cursor movement in the
compose box's topic input.)

Both searches then run to completion and notify twice. That's
harmless today beyond the wasted work, since both see the same data.
But soon, changes to the data being searched will also start a
same-query search (zulip#2137, zulip#2326: e.g. on any change to `Topics`),
and then we don't want the results to depend on which of two
searches over different data finishes first.

So count searches with a generation number instead: capture it when
a search starts, and stop when it has moved on. (This follows the
pattern of `MessageListView.generation` for invalidating stale
fetches.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
sm-sayedi added a commit to sm-sayedi/zulip-flutter that referenced this pull request Oct 27, 2026
In the next commit(s), this will become handy for efficiently
determining if a topic exists, in constant time.

Prompted by zulip#2137 (comment).
sm-sayedi and others added 18 commits October 28, 2026 23:09
This was missed when we first added the channel link autocomplete feature.
In the next commit(s), this will become handy for efficiently
determining if a topic exists, in constant time.

Prompted by zulip#2137 (comment).
`shouldStop` awaits `Future(() {})`, i.e. `Timer.run`,
which schedules an event-queue task, not a microtask.

The Dart event loop has two queues:
the event queue and the microtask queue.
Each time an item is dequeued from the event queue,
the event loop first drains the microtask queue.

Read more about the two queues:
  https://web.archive.org/web/20170704074724/https://webdev.dartlang.org/articles/performance/event-loop
As of this commit, it's not yet possible in the app to initiate a
topic link autocomplete interaction. So in the widgets code that would
consume the results of such an interaction, we just throw for now,
leaving that to be implemented in a later commit.

Co-authored-by: Chris Bobbe <cbobbe@zulip.com>
This will be used in the next commits for generating incomplete syntax
in addition to the previous completed syntax. The new incomplete syntax
will be inserted into the compose box when a channel is chosen from the
channel autocomplete. This way, the topic autocomplete interaction will
be readily available. The old completed syntax will be inserted into the
compose box when the channel is chosen from the topic autocomplete.
This will be used in the next commits for generating topic link syntax in
the compose box when a topic is chosen from the topic link autocomplete.
…election

This comment in `ComposeContentAutocomplete.autocompleteIntent`
already says that we don't require [isCollapsed], because
backspace and autocorrect transiently expand the selection to the left.
Say what requiring it would cost: the widgets code tears down
and recreates the view-model,
causing the options list to stay hidden until the next edit,
and the wasted work of recomputing the results from scratch
makes backspacing through a query laggy and janky, with dropped frames.
We already cancel out an autocomplete intent whenever the selection is
expanding to the left of the intent character (e.g @ or #).
To simplify this, we can start the search for the intent character from
the selection start, going leftwards.

This is basically NFC, but in the new approach if there's an
autocomplete intent inside the selection that will be cancelled,
it still keeps looking for another autocomplete intent to the left,
while previously it would cancel the looking process too.

I think simplifying this part of the code is worth the additional
looking which is basically harmless.
Also, simplify some of the implementation comments
by removing the details that are already mentioned in the dartdoc.
Additionally, add a new implementation comment to nameCharExclusions.
This will soon be shared by other regexes in the file.
For this commit we temporarily intercept the query at the
AutocompleteField widget, to avoid invoking the widgets that are
still unimplemented. That lets us defer those widgets' logic to a
separate later commit.
A `TextInputFormatter` for the compose content input that will be used
in the next commit(s) to replace a channel (fallback) link followed by
a ">", with a pending topic link autocomplete syntax.
From the web repo; edited in Inkscape to remove the padding around it.
   https://github.com/zulip/zulip/blob/052655eee/web/icons/corner-down-right.svg
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

integration review Added by maintainers when PR may be ready for integration

Projects

None yet

Development

Successfully merging this pull request may close these issues.

stream/topic autocomplete (e.g. #**mobile**)

4 participants