From 9ec7fdadde2fdc1d1482b57c48a205d320bf872c Mon Sep 17 00:00:00 2001 From: cogwheel0 <172976095+cogwheel0@users.noreply.github.com> Date: Mon, 15 Dec 2025 20:31:40 +0530 Subject: [PATCH] feat(chat): Add loading state for conversation title and model selector --- lib/features/chat/views/chat_page.dart | 220 ++++++++++++++++--------- 1 file changed, 144 insertions(+), 76 deletions(-) diff --git a/lib/features/chat/views/chat_page.dart b/lib/features/chat/views/chat_page.dart index f6de623..917c1d4 100644 --- a/lib/features/chat/views/chat_page.dart +++ b/lib/features/chat/views/chat_page.dart @@ -1502,11 +1502,14 @@ class _ChatPageState extends ConsumerState { trimmedConversationTitle.isNotEmpty) ? trimmedConversationTitle : null; + // Watch loading state for app bar skeleton + final isLoadingConversation = ref.watch(isLoadingConversationProvider); final formattedModelName = selectedModel != null ? _formatModelDisplayName(selectedModel.name) : null; final modelLabel = formattedModelName ?? l10n.chooseModel; - final hasConversationTitle = displayConversationTitle != null; + final hasConversationTitle = + displayConversationTitle != null || isLoadingConversation; final TextStyle modelTextStyle = hasConversationTitle ? AppTypography.small.copyWith( color: context.conduitTheme.textSecondary, @@ -1746,8 +1749,27 @@ class _ChatPageState extends ConsumerState { : LayoutBuilder( builder: (context, constraints) { // Build title pill (tappable for context menu) + // Show skeleton when loading, actual title otherwise Widget? titlePill; - if (displayConversationTitle != null) { + if (isLoadingConversation) { + // Show skeleton pill while loading conversation + titlePill = _buildAppBarPill( + context: context, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.md, + vertical: Spacing.xs, + ), + child: ConduitLoading.skeleton( + width: 120, + height: 18, + borderRadius: BorderRadius.circular( + AppBorderRadius.sm, + ), + ), + ), + ); + } else if (displayConversationTitle != null) { titlePill = GestureDetector( onTap: () { final conversation = ref.read( @@ -1795,95 +1817,141 @@ class _ChatPageState extends ConsumerState { } // Build model selector pill - final modelPill = GestureDetector( - onTap: () async { - final modelsAsync = ref.read(modelsProvider); - - if (modelsAsync.isLoading) { - try { - final models = await ref.read( - modelsProvider.future, - ); - if (!mounted) return; - // ignore: use_build_context_synchronously - _showModelDropdown(context, ref, models); - } catch (e) { - DebugLogger.error( - 'model-load-failed', - scope: 'chat/model-selector', - error: e, - ); - } - } else if (modelsAsync.hasValue) { - _showModelDropdown( - context, - ref, - modelsAsync.value!, - ); - } else if (modelsAsync.hasError) { - try { - ref.invalidate(modelsProvider); - final models = await ref.read( - modelsProvider.future, - ); - if (!mounted) return; - // ignore: use_build_context_synchronously - _showModelDropdown(context, ref, models); - } catch (e) { - DebugLogger.error( - 'model-refresh-failed', - scope: 'chat/model-selector', - error: e, - ); - } - } - }, - child: _buildAppBarPill( + // Show skeleton when loading, actual model selector otherwise + final Widget modelPill; + if (isLoadingConversation) { + // Show skeleton pill while loading conversation + modelPill = _buildAppBarPill( context: context, child: Padding( padding: const EdgeInsets.symmetric( horizontal: Spacing.sm, vertical: Spacing.xs, ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - ConstrainedBox( - constraints: BoxConstraints( - maxWidth: - constraints.maxWidth - - Spacing.xxl, - ), - child: MiddleEllipsisText( - modelLabel, - style: modelTextStyle, - textAlign: TextAlign.center, - semanticsLabel: modelLabel, - ), - ), - const SizedBox(width: Spacing.xs), - Icon( - Platform.isIOS - ? CupertinoIcons.chevron_down - : Icons.keyboard_arrow_down, - color: - context.conduitTheme.iconSecondary, - size: IconSize.small, - ), - ], + child: ConduitLoading.skeleton( + width: 80, + height: 14, + borderRadius: BorderRadius.circular( + AppBorderRadius.sm, + ), ), ), - ), - ); + ); + } else { + modelPill = GestureDetector( + onTap: () async { + final modelsAsync = ref.read(modelsProvider); + + if (modelsAsync.isLoading) { + try { + final models = await ref.read( + modelsProvider.future, + ); + if (!mounted) return; + // ignore: use_build_context_synchronously + _showModelDropdown(context, ref, models); + } catch (e) { + DebugLogger.error( + 'model-load-failed', + scope: 'chat/model-selector', + error: e, + ); + } + } else if (modelsAsync.hasValue) { + _showModelDropdown( + context, + ref, + modelsAsync.value!, + ); + } else if (modelsAsync.hasError) { + try { + ref.invalidate(modelsProvider); + final models = await ref.read( + modelsProvider.future, + ); + if (!mounted) return; + // ignore: use_build_context_synchronously + _showModelDropdown(context, ref, models); + } catch (e) { + DebugLogger.error( + 'model-refresh-failed', + scope: 'chat/model-selector', + error: e, + ); + } + } + }, + child: _buildAppBarPill( + context: context, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.sm, + vertical: Spacing.xs, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + ConstrainedBox( + constraints: BoxConstraints( + maxWidth: + constraints.maxWidth - + Spacing.xxl, + ), + child: MiddleEllipsisText( + modelLabel, + style: modelTextStyle, + textAlign: TextAlign.center, + semanticsLabel: modelLabel, + ), + ), + const SizedBox(width: Spacing.xs), + Icon( + Platform.isIOS + ? CupertinoIcons.chevron_down + : Icons.keyboard_arrow_down, + color: + context.conduitTheme.iconSecondary, + size: IconSize.small, + ), + ], + ), + ), + ), + ); + } return Column( mainAxisSize: MainAxisSize.min, children: [ if (titlePill != null) ...[ - titlePill, + AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeIn, + child: KeyedSubtree( + key: ValueKey( + isLoadingConversation + ? 'loading' + : 'title-$displayConversationTitle', + ), + child: titlePill, + ), + ), const SizedBox(height: Spacing.xs), ], - modelPill, + AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeIn, + child: KeyedSubtree( + key: ValueKey( + isLoadingConversation + ? 'model-loading' + : 'model-$modelLabel', + ), + child: modelPill, + ), + ), if (isReviewerMode) Padding( padding: const EdgeInsets.only(