Skip to content

Commit 8c49591

Browse files
committed
feat(chat): add ch.at auto session titles with privacy toggle
- generate titles in background from first 3 user + 3 assistant text messages and stop after consolidation - prevent stale async title updates on context/session switches and avoid regeneration on reopen when already consolidated - add per-server "Enable AI generated titles" setting (default off) and wire persistence through app provider flows - register ChatAtTitleGenerator in DI and send platform-aware word-limit instructions via prompt (4 mobile, 6 active desktop) - expand provider/widget/integration tests and update ROADMAP/CODEBASE documentation
1 parent 5485265 commit 8c49591

12 files changed

Lines changed: 1066 additions & 19 deletions

File tree

CODEBASE.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -575,6 +575,7 @@ Dependency injection via `get_it`. HTTP via `dio`. State management via `provide
575575
- Multi-server profile management (`ServerProfile`) with active/default selection
576576
- Per-server basic auth configuration and URL normalization
577577
- Health-aware activation (`/global/health`, fallback `/path`)
578+
- Legacy migration creates a profile only when legacy server/auth keys exist; clean installs now keep an empty server list until the user adds one
578579

579580
### Session Module
580581
- Session list loading and caching
@@ -584,6 +585,7 @@ Dependency injection via `get_it`. HTTP via `dio`. State management via `provide
584585
- Last-session snapshot persistence (`session + messages`) scoped by `server + directory` for instant startup restore
585586
- Stale-while-revalidate startup flow: cached last conversation renders immediately and message list revalidates silently in background
586587
- Full lifecycle operations: create/delete/rename/archive/unarchive/share/unshare/fork
588+
- Automatic AI title generation via `ch.at` after each user/assistant turn using up to the first 3 user + 3 assistant text messages, with per-session consolidation guard to stop after the 3+3 baseline is reached
587589
- Session insights orchestration: status snapshot + children/todo/diff hydration
588590
- Optimistic session mutations with rollback on API failure
589591
- Server-scoped cache isolation to prevent cross-server leakage
@@ -613,6 +615,7 @@ Dependency injection via `get_it`. HTTP via `dio`. State management via `provide
613615
- In-chat permission/question cards with actionable replies
614616
- Directory-scoped context snapshots and dirty-context refresh strategy
615617
- Chat-first shell: `AppShellPage` mounts `ChatPage` as primary route; `Logs` and `Settings` open as secondary routes with native back navigation to chat
618+
- Startup onboarding guard: when no server profile exists, `AppShellPage` routes directly to `Settings > Servers` (mobile and desktop)
616619
- Responsive shell with mobile drawer and desktop split-view layout
617620
- Sidebar top action row appears above `Conversations`: compact one-line `Logs` and `Settings` buttons open secondary routes while chat remains implicit as the primary area
618621
- Desktop shortcuts for new chat, refresh, input focus, and agent cycle
@@ -634,6 +637,7 @@ Dependency injection via `get_it`. HTTP via `dio`. State management via `provide
634637
- dedicated Sounds section was removed after Notifications absorbed all per-category sound controls
635638
- Server management moved into a dedicated `Servers` section:
636639
- add/edit/remove, active/default, health badges and activation guard
640+
- per-server privacy toggle `Enable AI generated titles` controls whether background title generation is allowed for that server profile
637641
- Chat keyboard shortcuts are now resolved from persisted settings via runtime binding parsing
638642
- Notification runtime adapters:
639643
- Android/Linux/macOS/Windows through `flutter_local_notifications`

ROADMAP.md

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -307,24 +307,37 @@ Refinamento de conflito de foco: durante hold em bolha o toque é absorvido e o
307307
- [ ] Emular `opencode serve` internamente como opção de servidor local (permitir ao CodeWalk iniciar e gerenciar um servidor OpenCode embutido sem depender de instância externa)
308308
- [x] Adotar stale-while-revalidate e manter a última sessão em cache local para UX instantânea ao abrir o app (servidores remotos podem levar até ~10s para responder, causando lag perceptível na abertura da última conversa)
309309
- [x] Auto-scroll para última mensagem como conversa de IM (WhatsApp/Telegram): avançar automaticamente para mensagens novas enquanto chegam, exceto quando usuário rolou para cima intencionalmente (detectar via botão seta pra baixo que aparece ao subir - significa que usuário está lendo histórico e não quer ser interrompido por auto-scroll)
310-
- [ ] Tool bash: mostrar o comando executado junto com o resultado (atualmente só mostra o output, não mostra qual comando gerou aquele resultado)
310+
- [x] Tool bash: mostrar o comando executado junto com o resultado (atualmente só mostra o output, não mostra qual comando gerou aquele resultado)
311311
- [ ] Fazer atalhos de teclado funcionarem de verdade
312312
- [ ] Exibir seção `Shortcuts` no mobile quando houver teclado físico conectado
313313
- [x] Cada tool call deve mostrar no máximo duas linhas com opção discreta de expandir para exibir todo o conteúdo
314-
- [ ] Remover o texto `tool call` das bolhas de tool call
314+
- [x] Remover o texto `tool call` das bolhas de tool call
315315
- [ ] Verificar atualizações baseadas nos releases do GitHub usando a API pública do GitHub
316-
- [ ] Em mobile, exibir apenas o ícone de status nas tool calls (sem textos como `Running`, `Completed`, `Finished`); manter o texto de status apenas no desktop
317-
- [ ] Usar o serviço `ch.at` para gerar títulos automaticamente
318-
- [ ] Usar a cor fornecida pelo agent via backend
319-
- [ ] Remover ícone do seletor de agente
320-
- [ ] Remover bordas dos selects de agent, provider/model e thinking
321-
- [ ] Subir o botão attach para ficar ao lado do select de agent, liberando mais espaço pro input de texto
316+
- [x] Em mobile, exibir apenas o ícone de status nas tool calls (sem textos como `Running`, `Completed`, `Finished`); manter o texto de status apenas no desktop
317+
- [x] Usar o serviço `ch.at` para gerar títulos automaticamente
318+
- [x] Usar a cor fornecida pelo agent via backend
319+
- [x] Remover ícone do seletor de agente
320+
- [x] Remover bordas dos selects de agent, provider/model e thinking
321+
- [x] Subir o botão attach para ficar ao lado do select de agent, liberando mais espaço pro input de texto
322+
- [ ] Inserir botão de fácil acesso para exibir/ocultar o Thinking (toggle rápido no header da mensagem ou área do composer)
323+
- [x] Simplificar barra de título: reduzir altura/padding, ocultar rótulos secundários, priorizar informações essenciais apenas
324+
- [x] Mover ícone/botão de status do servidor para dentro da barra lateral; mostrar pontinho indicador no menu hamburguer quando servidor não estiver verde (desconectado/degradado)
325+
- [x] Mover botão de logs para dentro de Settings (consolidar navegação secundária)
326+
- [x] Transformar botão Settings em apenas um ícone (⚙️), removendo rótulo de texto
327+
- [x] Quando não houver nenhum servidor cadastrado, redirecionar automaticamente para tela de servidores ao abrir o app
328+
- [ ] Adicionar opção em Settings para escolher densidade de todos os elementos do app (denso, normal, espaçoso)
329+
- [ ] Refresh providers/model em background de forma assíncrona ao abrir o app
330+
- [ ] Adicionar instruções básicas de como instalar e executar um servidor OpenCode na tela de adicionar servidor
322331

323332
Pronta para análise: adicionada ação de arquivamento na seção de contextos fechados para ocultar projects fechados sem depender de `worktree`, com persistência por servidor para manter a curadoria da lista ao reiniciar/recarregar. Também foi implementado cache persistido da última sessão (sessão + mensagens) por escopo (`server + directory`) com restore instantâneo na abertura e revalidação silenciosa em background (SWR), preservando usabilidade mesmo com latência alta do servidor remoto. No mobile, o envio pelo teclado agora usa ação `send` e oculta o teclado/foco após submissão para liberar mais espaço da conversa.
333+
Pronta para análise: o status/switch de servidor foi movido do app bar para a sidebar com controle centralizado e indicador de alerta no botão hambúrguer quando a conexão/saúde não está verde; `Logs` foi consolidado dentro de `Settings` como seção dedicada; e o acesso de `Settings` na sidebar foi simplificado para botão somente com ícone (sem rótulo textual), mantendo tooltip e cobertura de testes de navegação/UX.
324334
Pronta para análise: comportamento de auto-scroll estilo IM foi finalizado no chat. Enquanto o usuário permanece no final da conversa, novas mensagens continuam acompanhando automaticamente; ao subir o histórico intencionalmente, o auto-follow pausa e a UI preserva a posição de leitura, destacando o FAB de retorno ao último item. Ao tocar no FAB, a conversa volta ao fim e o auto-follow é reativado.
335+
Pronta para análise: os controles de agent/model/thinking foram refinados para visual sem borda, o seletor de agente ficou sem ícone e passou a aplicar a `color` recebida do backend (quando disponível) no chip e no menu de seleção. O botão de anexos foi movido para a barra de controles ao lado dos seletores, liberando espaço horizontal no composer sem alterar o fluxo de anexar imagens/PDF.
325336
Pronta para análise: tool calls agora iniciam com preview compacto de no máximo duas linhas para saída/erro e exibem ação discreta de expandir/recolher (`Show more`/`Show less`) para alternar entre visão resumida e conteúdo completo, reduzindo ruído visual sem perder acesso ao detalhe.
326337
Refinamento pós-entrega aplicado nos blocos de thinking: o bloco mais recente permanece expandido por padrão, e blocos anteriores são automaticamente colapsados quando um novo bloco de thinking é adicionado, mantendo opção manual de expandir/recolher em cada bloco.
327338
Correção pós-validação aplicada: o critério de "bloco mais recente" passou a ser global na conversa (não apenas dentro da mesma mensagem), garantindo que thinkings anteriores realmente colapsem quando um novo thinking surgir em outro bloco/mensagem.
339+
Pronta para análise: tool calls agora exibem comando executado (com extração resiliente de `input.command`/`input.cmd` e fallback legível), removeram o prefixo textual `Tool Call` do cabeçalho e aplicam status compacto por ícone no mobile, preservando label textual apenas no desktop/web. Cobertura de widget foi ampliada para validar comando visível, remoção do texto antigo e diferença mobile/desktop no chip de status.
340+
Pronta para análise: onboarding de servidores aplicado no shell principal. Quando não há nenhum servidor configurado, o app abre automaticamente em `Settings > Servers` (mobile e desktop), com guarda de inicialização para evitar redirecionamento incorreto antes de carregar estado local. Também foi removida a criação automática de servidor padrão em instalações sem legado, habilitando estado inicial realmente vazio.
328341

329342
## Code Quality and Technical Debt
330343

lib/core/di/injection_container.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import '../../presentation/providers/chat_provider.dart';
4545
import '../../presentation/providers/project_provider.dart';
4646
import '../../presentation/providers/settings_provider.dart';
4747
import '../../presentation/services/event_feedback_dispatcher.dart';
48+
import '../../presentation/services/chat_title_generator.dart';
4849
import '../../presentation/services/notification_service.dart';
4950
import '../../presentation/services/sound_service.dart';
5051

@@ -78,6 +79,7 @@ Future<void> init() async {
7879

7980
sl.registerLazySingleton(() => NotificationService());
8081
sl.registerLazySingleton(() => SoundService());
82+
sl.registerLazySingleton<ChatTitleGenerator>(() => ChatAtTitleGenerator());
8183

8284
// Repositories
8385
sl.registerLazySingleton<AppRepository>(
@@ -164,6 +166,7 @@ Future<void> init() async {
164166
projectProvider: sl(),
165167
localDataSource: sl(),
166168
eventFeedbackDispatcher: sl(),
169+
titleGenerator: sl(),
167170
),
168171
);
169172

lib/domain/entities/server_profile.dart

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ class ServerProfile extends Equatable {
88
this.basicAuthEnabled = false,
99
this.basicAuthUsername = '',
1010
this.basicAuthPassword = '',
11+
this.aiGeneratedTitlesEnabled = false,
1112
required this.createdAt,
1213
required this.updatedAt,
1314
});
@@ -18,6 +19,7 @@ class ServerProfile extends Equatable {
1819
final bool basicAuthEnabled;
1920
final String basicAuthUsername;
2021
final String basicAuthPassword;
22+
final bool aiGeneratedTitlesEnabled;
2123
final int createdAt;
2224
final int updatedAt;
2325

@@ -37,6 +39,7 @@ class ServerProfile extends Equatable {
3739
'basicAuthEnabled': basicAuthEnabled,
3840
'basicAuthUsername': basicAuthUsername,
3941
'basicAuthPassword': basicAuthPassword,
42+
'aiGeneratedTitlesEnabled': aiGeneratedTitlesEnabled,
4043
'createdAt': createdAt,
4144
'updatedAt': updatedAt,
4245
};
@@ -50,6 +53,8 @@ class ServerProfile extends Equatable {
5053
basicAuthEnabled: json['basicAuthEnabled'] as bool? ?? false,
5154
basicAuthUsername: json['basicAuthUsername'] as String? ?? '',
5255
basicAuthPassword: json['basicAuthPassword'] as String? ?? '',
56+
aiGeneratedTitlesEnabled:
57+
json['aiGeneratedTitlesEnabled'] as bool? ?? false,
5358
createdAt: json['createdAt'] as int? ?? 0,
5459
updatedAt: json['updatedAt'] as int? ?? 0,
5560
);
@@ -62,6 +67,7 @@ class ServerProfile extends Equatable {
6267
bool? basicAuthEnabled,
6368
String? basicAuthUsername,
6469
String? basicAuthPassword,
70+
bool? aiGeneratedTitlesEnabled,
6571
int? createdAt,
6672
int? updatedAt,
6773
}) {
@@ -72,6 +78,8 @@ class ServerProfile extends Equatable {
7278
basicAuthEnabled: basicAuthEnabled ?? this.basicAuthEnabled,
7379
basicAuthUsername: basicAuthUsername ?? this.basicAuthUsername,
7480
basicAuthPassword: basicAuthPassword ?? this.basicAuthPassword,
81+
aiGeneratedTitlesEnabled:
82+
aiGeneratedTitlesEnabled ?? this.aiGeneratedTitlesEnabled,
7583
createdAt: createdAt ?? this.createdAt,
7684
updatedAt: updatedAt ?? this.updatedAt,
7785
);
@@ -85,6 +93,7 @@ class ServerProfile extends Equatable {
8593
basicAuthEnabled,
8694
basicAuthUsername,
8795
basicAuthPassword,
96+
aiGeneratedTitlesEnabled,
8897
createdAt,
8998
updatedAt,
9099
];

lib/presentation/pages/settings/sections/servers_settings_section.dart

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,7 @@ class _ServersSettingsSectionState extends State<ServersSettingsSection> {
303303
text: initial?.basicAuthPassword ?? '',
304304
);
305305
var basicAuthEnabled = initial?.basicAuthEnabled ?? false;
306+
var aiGeneratedTitlesEnabled = initial?.aiGeneratedTitlesEnabled ?? false;
306307
final formKey = GlobalKey<FormState>();
307308

308309
final shouldSave = await showDialog<bool>(
@@ -384,6 +385,24 @@ class _ServersSettingsSectionState extends State<ServersSettingsSection> {
384385
},
385386
),
386387
],
388+
const SizedBox(height: 6),
389+
SwitchListTile(
390+
value: aiGeneratedTitlesEnabled,
391+
onChanged: (value) {
392+
setState(() {
393+
aiGeneratedTitlesEnabled = value;
394+
});
395+
},
396+
contentPadding: EdgeInsets.zero,
397+
title: const Text('Enable AI generated titles'),
398+
),
399+
const Align(
400+
alignment: Alignment.centerLeft,
401+
child: Text(
402+
'This is a free service powered by https://ch.at. Disable if you mind about your privacy.',
403+
style: TextStyle(fontSize: 12),
404+
),
405+
),
387406
],
388407
),
389408
),
@@ -424,6 +443,7 @@ class _ServersSettingsSectionState extends State<ServersSettingsSection> {
424443
basicAuthEnabled: basicAuthEnabled,
425444
basicAuthUsername: usernameController.text.trim(),
426445
basicAuthPassword: passwordController.text.trim(),
446+
aiGeneratedTitlesEnabled: aiGeneratedTitlesEnabled,
427447
setAsActive: appProvider.serverProfiles.isEmpty,
428448
)
429449
: await appProvider.updateServerProfile(
@@ -433,6 +453,7 @@ class _ServersSettingsSectionState extends State<ServersSettingsSection> {
433453
basicAuthEnabled: basicAuthEnabled,
434454
basicAuthUsername: usernameController.text.trim(),
435455
basicAuthPassword: passwordController.text.trim(),
456+
aiGeneratedTitlesEnabled: aiGeneratedTitlesEnabled,
436457
);
437458

438459
if (!success) {

lib/presentation/providers/app_provider.dart

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,17 @@ class AppProvider extends ChangeNotifier {
159159
final oldBasicUser = await _localDataSource.getBasicAuthUsername();
160160
final oldBasicPassword = await _localDataSource.getBasicAuthPassword();
161161

162+
final hasLegacyHost = oldHost != null && oldHost.trim().isNotEmpty;
163+
final hasLegacyPort = oldPort != null;
164+
final hasLegacyAuth =
165+
(oldBasicEnabled ?? false) ||
166+
(oldBasicUser != null && oldBasicUser.trim().isNotEmpty) ||
167+
(oldBasicPassword != null && oldBasicPassword.trim().isNotEmpty);
168+
if (!hasLegacyHost && !hasLegacyPort && !hasLegacyAuth) {
169+
_serverProfiles = <ServerProfile>[];
170+
return;
171+
}
172+
162173
final host = (oldHost == null || oldHost.trim().isEmpty)
163174
? ApiConstants.defaultHost
164175
: oldHost.trim();
@@ -172,6 +183,7 @@ class AppProvider extends ChangeNotifier {
172183
basicAuthEnabled: oldBasicEnabled ?? false,
173184
basicAuthUsername: oldBasicUser ?? '',
174185
basicAuthPassword: oldBasicPassword ?? '',
186+
aiGeneratedTitlesEnabled: false,
175187
createdAt: now,
176188
updatedAt: now,
177189
);
@@ -183,6 +195,9 @@ class AppProvider extends ChangeNotifier {
183195

184196
Future<void> _ensureActiveSelection() async {
185197
if (_serverProfiles.isEmpty) {
198+
_activeServerId = null;
199+
_defaultServerId = null;
200+
await _localDataSource.saveDefaultServerId(null);
186201
return;
187202
}
188203

@@ -226,6 +241,9 @@ class AppProvider extends ChangeNotifier {
226241
void _applyActiveServerToClient() {
227242
final profile = activeServer;
228243
if (profile == null) {
244+
_dioClient.clearAuth();
245+
_serverHost = ApiConstants.defaultHost;
246+
_serverPort = ApiConstants.defaultPort;
229247
return;
230248
}
231249
_dioClient.updateBaseUrl(profile.url);
@@ -253,6 +271,7 @@ class AppProvider extends ChangeNotifier {
253271
bool basicAuthEnabled = false,
254272
String basicAuthUsername = '',
255273
String basicAuthPassword = '',
274+
bool aiGeneratedTitlesEnabled = false,
256275
bool setAsActive = false,
257276
}) async {
258277
await initialize();
@@ -275,6 +294,7 @@ class AppProvider extends ChangeNotifier {
275294
basicAuthEnabled: basicAuthEnabled,
276295
basicAuthUsername: basicAuthUsername.trim(),
277296
basicAuthPassword: basicAuthPassword.trim(),
297+
aiGeneratedTitlesEnabled: aiGeneratedTitlesEnabled,
278298
createdAt: now,
279299
updatedAt: now,
280300
);
@@ -298,6 +318,7 @@ class AppProvider extends ChangeNotifier {
298318
required bool basicAuthEnabled,
299319
required String basicAuthUsername,
300320
required String basicAuthPassword,
321+
required bool aiGeneratedTitlesEnabled,
301322
}) async {
302323
await initialize();
303324
final index = _serverProfiles.indexWhere((p) => p.id == id);
@@ -327,6 +348,7 @@ class AppProvider extends ChangeNotifier {
327348
basicAuthEnabled: basicAuthEnabled,
328349
basicAuthUsername: basicAuthUsername.trim(),
329350
basicAuthPassword: basicAuthPassword.trim(),
351+
aiGeneratedTitlesEnabled: aiGeneratedTitlesEnabled,
330352
updatedAt: DateTime.now().millisecondsSinceEpoch,
331353
);
332354
final copied = List<ServerProfile>.from(_serverProfiles);
@@ -346,11 +368,6 @@ class AppProvider extends ChangeNotifier {
346368

347369
Future<bool> removeServerProfile(String id) async {
348370
await initialize();
349-
if (_serverProfiles.length <= 1) {
350-
_setError('At least one server profile is required');
351-
return false;
352-
}
353-
354371
final exists = _serverProfiles.any((p) => p.id == id);
355372
if (!exists) {
356373
_setError('Server profile not found');
@@ -360,6 +377,18 @@ class AppProvider extends ChangeNotifier {
360377
_serverProfiles = _serverProfiles.where((p) => p.id != id).toList();
361378
_serverHealthById.remove(id);
362379

380+
if (_serverProfiles.isEmpty) {
381+
_activeServerId = null;
382+
_defaultServerId = null;
383+
_isConnected = false;
384+
_appInfo = null;
385+
_applyActiveServerToClient();
386+
await _persistServerProfiles();
387+
_errorMessage = '';
388+
notifyListeners();
389+
return true;
390+
}
391+
363392
if (_defaultServerId == id) {
364393
_defaultServerId = _serverProfiles.first.id;
365394
}
@@ -531,6 +560,7 @@ class AppProvider extends ChangeNotifier {
531560
basicAuthEnabled: current.basicAuthEnabled,
532561
basicAuthUsername: current.basicAuthUsername,
533562
basicAuthPassword: current.basicAuthPassword,
563+
aiGeneratedTitlesEnabled: current.aiGeneratedTitlesEnabled,
534564
);
535565
}
536566

0 commit comments

Comments
 (0)