feat(studio): central error helpers + inline log viewer + Today CTAs
Some checks failed
Security / Security check (push) Failing after 1s
Some checks failed
Security / Security check (push) Failing after 1s
Bundles the error-UX overhaul and the inline log viewer:
- New `FaiLog` (`~/.fai/logs/studio-errors.log`, 256 KiB rotation,
JSON-per-line). Every operator-visible failure is appended so
`fai admin doctor` and the new viewer can show the trail
without the operator having to reproduce the failure.
- New `showFaiErrorSnack` / `showFaiErrorDialog` helpers wrap
`FaiErrorBox` in copyable surfaces; 27 ad-hoc
`SnackBar(content: Text(e.toString()))` sites swept to use
them (settings, doctor, audit, store, module sheet, system-AI
editor, flow output).
- New `FaiLogViewer` modal (`showFaiLogViewer`) renders log
files inline with line numbers, JSON-key + `[level]` token
colouring, Copy-all, Refresh, Open-externally. Doctor's
daemon-paths panel grows a "View" button next to "Open" for
every `.log` row and now also lists the Studio errors log.
- Today carousel: CTAs now actually re-run search after a
`filterCategory` / `runQuery` story is tapped (was only
flipping the chip state). Fallback story list bumped to 8.
- Editor bumped to git ref carrying 0.15.0 (type-token
colouring + analyzer diagnostics).
Studio bumped to 0.62.0.
Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
parent
2731062a13
commit
8f5cd2528b
22 changed files with 1087 additions and 134 deletions
|
|
@ -4,6 +4,7 @@ import 'dart:io';
|
|||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../data/error_presentation.dart';
|
||||
import '../data/hub.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../theme/theme.dart';
|
||||
|
|
@ -53,21 +54,16 @@ class _AuditPageState extends State<AuditPage> {
|
|||
_refresh();
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(l.auditClearFailed(_friendly(e)))));
|
||||
showFaiErrorSnack(context, 'audit.clear', e);
|
||||
}
|
||||
}
|
||||
|
||||
String _friendly(Object e) {
|
||||
final s = e.toString();
|
||||
// The hub returns FailedPrecondition for compliance-locked
|
||||
// channels; tonic stringifies that as "failed_precondition:
|
||||
// …". Strip the noise so the operator sees the real reason.
|
||||
final marker = 'failed_precondition: ';
|
||||
final idx = s.toLowerCase().indexOf(marker);
|
||||
return idx >= 0 ? s.substring(idx + marker.length) : s;
|
||||
}
|
||||
// _friendly was an inline shadow of friendlyError() in
|
||||
// data/friendly_error.dart — kept only because the original
|
||||
// call site predated the central mapper. Removed in favour
|
||||
// of showFaiErrorSnack, which uses friendlyError under the
|
||||
// hood (with proper gRPC-code mapping + selectable
|
||||
// copy-affordance).
|
||||
|
||||
Future<void> _refresh() async {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../data/error_presentation.dart';
|
||||
import '../data/fai_log.dart';
|
||||
import '../data/hub.dart';
|
||||
import '../data/system_actions.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
|
|
@ -343,6 +345,12 @@ class _DaemonPathsPanel extends StatelessWidget {
|
|||
// Explorer instead of failing the OS "open" handler.
|
||||
final entries = <(String, String, IconData, bool)>[
|
||||
(l.doctorPathLog, paths.logPath, Icons.description_outlined, false),
|
||||
(
|
||||
l.doctorPathStudioErrors,
|
||||
FaiLog.instance.path,
|
||||
Icons.report_problem_outlined,
|
||||
false,
|
||||
),
|
||||
(l.doctorPathConfig, paths.configPath, Icons.settings_outlined, false),
|
||||
(l.doctorPathDb, paths.dbPath, Icons.storage_outlined, false),
|
||||
(l.doctorPathModules, paths.modulesDir, Icons.extension_outlined, true),
|
||||
|
|
@ -387,6 +395,8 @@ class _PathRow extends StatelessWidget {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final l = AppLocalizations.of(context)!;
|
||||
final isLog = !isDirectory && path.toLowerCase().endsWith('.log');
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
|
|
@ -412,6 +422,18 @@ class _PathRow extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
const SizedBox(width: FaiSpace.sm),
|
||||
if (isLog) ...[
|
||||
OutlinedButton.icon(
|
||||
onPressed: () =>
|
||||
showFaiLogViewer(context, path: path, title: label),
|
||||
icon: const Icon(Icons.visibility_outlined, size: 14),
|
||||
label: Text(l.buttonView),
|
||||
style: OutlinedButton.styleFrom(
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: FaiSpace.xs),
|
||||
],
|
||||
OutlinedButton.icon(
|
||||
onPressed: () async {
|
||||
final r = await SystemActions.openOrReveal(
|
||||
|
|
@ -420,9 +442,10 @@ class _PathRow extends StatelessWidget {
|
|||
);
|
||||
if (!context.mounted) return;
|
||||
if (!r.ok) {
|
||||
final l = AppLocalizations.of(context)!;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l.doctorOpenError(r.stderr))),
|
||||
showFaiErrorSnack(
|
||||
context,
|
||||
'doctor.open-path',
|
||||
r.stderr.isEmpty ? 'open failed' : r.stderr,
|
||||
);
|
||||
}
|
||||
},
|
||||
|
|
@ -430,7 +453,7 @@ class _PathRow extends StatelessWidget {
|
|||
isDirectory ? Icons.folder_open : Icons.open_in_new,
|
||||
size: 14,
|
||||
),
|
||||
label: Text(AppLocalizations.of(context)!.buttonOpen),
|
||||
label: Text(l.buttonOpen),
|
||||
style: OutlinedButton.styleFrom(
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||
|
||||
import '../data/error_presentation.dart';
|
||||
import '../data/hub.dart';
|
||||
import '../data/system_actions.dart';
|
||||
import '../data/today_story_loader.dart';
|
||||
|
|
@ -310,19 +311,16 @@ class _StorePageState extends State<StorePage> {
|
|||
_source.isEmpty &&
|
||||
!_installedOnly;
|
||||
final hasNoFederation = !raw.any((e) => e.isFederated);
|
||||
// Past-onboarding signal: the operator has
|
||||
// 3+ modules installed (wasm + federated
|
||||
// combined). At that point the editorial
|
||||
// hero stops earning its scroll cost — they
|
||||
// know what F∆I does. They can still bring
|
||||
// it back via the "Tour" button (TODO) or
|
||||
// by clearing _todayDismissed in prefs.
|
||||
final installedCount = raw
|
||||
.where((e) => e.installed)
|
||||
.length;
|
||||
final pastOnboarding = installedCount >= 3;
|
||||
final showToday =
|
||||
!_todayDismissed && isBrowsing && !pastOnboarding;
|
||||
// Today carousel stays visible until the
|
||||
// operator explicitly dismisses it. The earlier
|
||||
// installedCount≥3 auto-hide killed the strip
|
||||
// for anyone past first-launch — meaning the
|
||||
// rotating editorial stories (themes, audit,
|
||||
// SDK, sandbox…) were never seen by experienced
|
||||
// operators, which is exactly the audience the
|
||||
// wider story set is for. One-click dismiss is
|
||||
// enough opt-out.
|
||||
final showToday = !_todayDismissed && isBrowsing;
|
||||
// Recommended-source quick-adds are inlined in
|
||||
// the Today footer when both apply, so the
|
||||
// operator never sees two competing editorial
|
||||
|
|
@ -385,22 +383,24 @@ class _StorePageState extends State<StorePage> {
|
|||
: null,
|
||||
onDismiss: () =>
|
||||
setState(() => _todayDismissed = true),
|
||||
onCta:
|
||||
onCta: _ctaCallbackFor(
|
||||
_todayStories[_todayIndex % _todayStories.length],
|
||||
),
|
||||
// Per-story brand chips: only the MCP
|
||||
// story shows DeepWiki / Semgrep
|
||||
// quick-adds, so the chips don't read
|
||||
// as ads on stories that aren't about
|
||||
// sources. Standalone "Suggested
|
||||
// sources" strip below still catches
|
||||
// the dismissed-Today / no-federation
|
||||
// edge case.
|
||||
recommendedSources:
|
||||
_todayStories[_todayIndex %
|
||||
_todayStories.length]
|
||||
.cta ==
|
||||
TodayCta.openSettings
|
||||
? () => FaiSettingsDialog.show(context)
|
||||
: null,
|
||||
// Quick-add chips removed from the
|
||||
// editorial hero — they showed the
|
||||
// same DeepWiki / Semgrep names every
|
||||
// session, which felt like ads. Public
|
||||
// sources still live one click away
|
||||
// under Settings → MCP Clients, and
|
||||
// the standalone "Suggested sources"
|
||||
// strip below picks them up.
|
||||
recommendedSources: const <_RecommendedSource>[],
|
||||
TodayCta.showSuggestedSources
|
||||
? _kRecommendedSources
|
||||
: const <_RecommendedSource>[],
|
||||
recommendedAdding: _addingSources,
|
||||
onAddRecommended: _addRecommendedSource,
|
||||
),
|
||||
|
|
@ -554,6 +554,47 @@ class _StorePageState extends State<StorePage> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Resolve a Today story's CTA descriptor into a real callback
|
||||
/// the hero card can wire to its button. `null` collapses the
|
||||
/// button out of the layout — used for narrative-only stories
|
||||
/// (audit / sandbox explainer) where there's nowhere obvious
|
||||
/// to send the operator next.
|
||||
VoidCallback? _ctaCallbackFor(TodayStoryData story) {
|
||||
switch (story.cta) {
|
||||
case TodayCta.none:
|
||||
return null;
|
||||
case TodayCta.openSettings:
|
||||
return () => FaiSettingsDialog.show(context);
|
||||
case TodayCta.filterCategory:
|
||||
if (story.ctaPayload.isEmpty) return null;
|
||||
return () {
|
||||
// Two-step refresh: setState makes the dropdown
|
||||
// header re-render with the new selection, then
|
||||
// _runSearch re-issues the backend query with
|
||||
// `category:` in its filter — without the second
|
||||
// call the grid keeps showing the unfiltered list
|
||||
// and the carousel button looked broken.
|
||||
setState(() => _category = story.ctaPayload);
|
||||
_runSearch();
|
||||
};
|
||||
case TodayCta.runQuery:
|
||||
if (story.ctaPayload.isEmpty) return null;
|
||||
return () {
|
||||
_queryCtrl.text = story.ctaPayload;
|
||||
setState(() {});
|
||||
_runSearch();
|
||||
};
|
||||
case TodayCta.showSuggestedSources:
|
||||
// Stays on this slide — the chips render in the hero
|
||||
// footer for stories with this CTA. The button just
|
||||
// scrolls focus to them; for now the chips are
|
||||
// immediately visible above the fold, so the click is
|
||||
// effectively a no-op acknowledgement. Keeping it
|
||||
// wired avoids a dead button.
|
||||
return () {};
|
||||
}
|
||||
}
|
||||
|
||||
void _clearQuery() {
|
||||
setState(() {
|
||||
_queryCtrl.clear();
|
||||
|
|
@ -669,10 +710,11 @@ class _StorePageState extends State<StorePage> {
|
|||
_runSearch();
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(l.storeProviderAddFailed(s.label, e.toString())),
|
||||
),
|
||||
showFaiErrorSnack(
|
||||
context,
|
||||
'store.provider.add',
|
||||
e,
|
||||
title: l.storeProviderAddFailed(s.label, ''),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _addingSources.remove(s.name));
|
||||
|
|
@ -2143,9 +2185,10 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
|
|||
final r = await SystemActions.openInOs(widget.item.repository);
|
||||
if (!mounted) return;
|
||||
if (!r.ok) {
|
||||
final l = AppLocalizations.of(context)!;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l.storeOpenBrowserFailed(r.stderr))),
|
||||
showFaiErrorSnack(
|
||||
context,
|
||||
'store.repository.open',
|
||||
r.stderr.isEmpty ? 'open failed' : r.stderr,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -2926,18 +2969,18 @@ class _ScreenshotPlaceholder extends StatelessWidget {
|
|||
/// on the dedicated Welcome page, not in the store carousel.
|
||||
const List<TodayStoryData> _kFallbackTodayStories = <TodayStoryData>[
|
||||
TodayStoryData(
|
||||
badgeEn: 'MODULES',
|
||||
badgeDe: 'MODULE',
|
||||
titleEn: 'Modules are the building blocks',
|
||||
titleDe: 'Module sind die Bausteine',
|
||||
badgeEn: 'MCP',
|
||||
badgeDe: 'MCP',
|
||||
titleEn: 'Plug in any MCP server',
|
||||
titleDe: 'Jeden MCP-Server einbinden',
|
||||
bodyEn:
|
||||
'Each tile in the grid below is a sandboxed WASM module. Pick one, hit Install, and the hub adds it to your local registry. Modules declare their permissions up front; the hub denies anything else.',
|
||||
'F∆I speaks the Model Context Protocol out of the box. Add a public server (DeepWiki, Semgrep) or a private one — its tools show up in this grid next to the built-in modules. Same install button, same audit trail.',
|
||||
bodyDe:
|
||||
'Jede Kachel unten ist ein sandboxes WASM-Modul. Auswählen, Installieren klicken — der Hub fügt es deiner lokalen Registry hinzu. Module deklarieren ihre Berechtigungen explizit; der Hub verweigert alles andere.',
|
||||
ctaLabelEn: '',
|
||||
ctaLabelDe: '',
|
||||
icon: Icons.extension_outlined,
|
||||
cta: TodayCta.none,
|
||||
'F∆I spricht das Model Context Protocol von Haus aus. Einen öffentlichen Server (DeepWiki, Semgrep) oder einen eigenen hinzufügen — seine Tools tauchen in diesem Grid neben den eingebauten Modulen auf. Gleicher Install-Button, gleiche Audit-Spur.',
|
||||
ctaLabelEn: 'Browse suggested sources',
|
||||
ctaLabelDe: 'Empfohlene Quellen ansehen',
|
||||
icon: Icons.hub_outlined,
|
||||
cta: TodayCta.showSuggestedSources,
|
||||
),
|
||||
TodayStoryData(
|
||||
badgeEn: 'FLOWS',
|
||||
|
|
@ -2948,24 +2991,97 @@ const List<TodayStoryData> _kFallbackTodayStories = <TodayStoryData>[
|
|||
'A flow is a YAML file with three keys — inputs, steps, outputs. Each step calls one module. Every run lands in the audit log with a verifiable hash chain. Build flows graphically or write the YAML directly — either way, the run is reproducible.',
|
||||
bodyDe:
|
||||
'Ein Flow ist eine YAML-Datei mit drei Schlüsseln — inputs, steps, outputs. Jeder Schritt ruft ein Modul auf. Jeder Lauf landet im Audit-Log mit verifizierbarer Hash-Kette. Flows grafisch bauen oder direkt YAML schreiben — der Lauf bleibt reproduzierbar.',
|
||||
ctaLabelEn: 'Show featured flows',
|
||||
ctaLabelDe: 'Featured-Flows zeigen',
|
||||
icon: Icons.account_tree_outlined,
|
||||
cta: TodayCta.runQuery,
|
||||
ctaPayload: 'hello',
|
||||
),
|
||||
TodayStoryData(
|
||||
badgeEn: 'THEMES',
|
||||
badgeDe: 'THEMES',
|
||||
titleEn: 'Make Studio yours — pick a theme',
|
||||
titleDe: 'Studio nach deinem Geschmack — Theme wählen',
|
||||
bodyEn:
|
||||
'Solarized, Sunflower, Space, Sunset, Glass-Apple, Accessibility — Studio plugins re-skin the whole app at runtime. Or pick a single accent colour and let Material 3 derive the rest. Browse the studio-plugin category for the full set.',
|
||||
bodyDe:
|
||||
'Solarized, Sunflower, Space, Sunset, Glass-Apple, Accessibility — Studio-Plugins themen die App zur Laufzeit komplett um. Oder eine einzelne Akzentfarbe wählen und Material 3 leitet den Rest ab. Die Kategorie Studio-Plugin zeigt das volle Set.',
|
||||
ctaLabelEn: 'Show themes',
|
||||
ctaLabelDe: 'Themes anzeigen',
|
||||
icon: Icons.palette_outlined,
|
||||
cta: TodayCta.filterCategory,
|
||||
ctaPayload: 'studio-plugin',
|
||||
),
|
||||
TodayStoryData(
|
||||
badgeEn: 'TEXT TOOLS',
|
||||
badgeDe: 'TEXT-TOOLS',
|
||||
titleEn: 'Extract, anonymise, translate, summarise',
|
||||
titleDe: 'Extrahieren, anonymisieren, übersetzen, zusammenfassen',
|
||||
bodyEn:
|
||||
'The text-processing bundle is F∆I\'s document-analysis backbone. PDF and DOCX in, plain text out. Then anonymise PII, translate, or summarise — every step audit-grade, every Ollama call recorded with model digest.',
|
||||
bodyDe:
|
||||
'Das Text-Processing-Bündel ist F∆Is Dokumentenanalyse-Rückgrat. PDF und DOCX rein, Klartext raus. Dann PII anonymisieren, übersetzen oder zusammenfassen — jeder Schritt audit-tauglich, jeder Ollama-Aufruf mit Modell-Digest protokolliert.',
|
||||
ctaLabelEn: 'Show text modules',
|
||||
ctaLabelDe: 'Text-Module zeigen',
|
||||
icon: Icons.article_outlined,
|
||||
cta: TodayCta.filterCategory,
|
||||
ctaPayload: 'text-processing',
|
||||
),
|
||||
TodayStoryData(
|
||||
badgeEn: 'SANDBOX',
|
||||
badgeDe: 'SANDBOX',
|
||||
titleEn: 'Every module declares its permissions',
|
||||
titleDe: 'Jedes Modul deklariert seine Berechtigungen',
|
||||
bodyEn:
|
||||
'F∆I modules are WASM components. They run in a sandbox with no ambient authority — every filesystem path, every network host, every env var has to be listed in module.yaml. The hub enforces them. No declaration, no access.',
|
||||
bodyDe:
|
||||
'F∆I-Module sind WASM-Komponenten. Sie laufen in einer Sandbox ohne Ambient-Authority — jeder Dateipfad, jeder Netzwerk-Host, jede Env-Var muss in module.yaml stehen. Der Hub setzt es durch. Keine Deklaration, kein Zugriff.',
|
||||
ctaLabelEn: 'Review permissions',
|
||||
ctaLabelDe: 'Berechtigungen prüfen',
|
||||
icon: Icons.shield_outlined,
|
||||
cta: TodayCta.openSettings,
|
||||
),
|
||||
TodayStoryData(
|
||||
badgeEn: 'AUDIT',
|
||||
badgeDe: 'AUDIT',
|
||||
titleEn: 'Tamper-evident hash chain — built in',
|
||||
titleDe: 'Manipulationssicher per Hash-Kette — eingebaut',
|
||||
bodyEn:
|
||||
'Every flow run, every install, every approval lands in ~/.fai/audit/ as a hash-chained event log. Any later edit invalidates the chain. CRA-ready out of the box — no compliance product to buy on top.',
|
||||
bodyDe:
|
||||
'Jeder Flow-Lauf, jede Installation, jede Freigabe landet in ~/.fai/audit/ als hash-verkettetes Event-Log. Jede spätere Änderung bricht die Kette. CRA-tauglich von Haus aus — kein Compliance-Produkt zum Aufkaufen nötig.',
|
||||
ctaLabelEn: '',
|
||||
ctaLabelDe: '',
|
||||
icon: Icons.account_tree_outlined,
|
||||
icon: Icons.timeline_outlined,
|
||||
cta: TodayCta.none,
|
||||
),
|
||||
TodayStoryData(
|
||||
badgeEn: 'EXTENSIBILITY',
|
||||
badgeDe: 'ERWEITERBAR',
|
||||
titleEn: 'Bring your own sources',
|
||||
titleDe: 'Eigene Quellen anbinden',
|
||||
badgeEn: 'TEST FIRST',
|
||||
badgeDe: 'ZUERST TESTEN',
|
||||
titleEn: 'fai doctor — diagnose before you ship',
|
||||
titleDe: 'fai doctor — diagnostizieren bevor ausgerollt wird',
|
||||
bodyEn:
|
||||
'The hub federates external module sources — public MCP servers, n8n workflows, your own internal registry. Configure a source under Settings → MCP Clients and its tools appear in this store alongside the built-in modules. Same install button, same audit trail.',
|
||||
'Before installing a module on a production hub, run `fai doctor` in the CLI. It validates manifest, signatures, declared permissions against the operator policy ceiling, and reports anything the hub would reject — no surprises at install time.',
|
||||
bodyDe:
|
||||
'Der Hub bindet externe Modul-Quellen ein — öffentliche MCP-Server, n8n-Workflows, eigene interne Registries. Eine Quelle unter Einstellungen → MCP-Clients konfigurieren, und ihre Tools erscheinen hier im Store neben den eingebauten Modulen. Gleicher Install-Button, gleiche Audit-Spur.',
|
||||
ctaLabelEn: 'Manage sources',
|
||||
ctaLabelDe: 'Quellen verwalten',
|
||||
icon: Icons.hub_outlined,
|
||||
cta: TodayCta.openSettings,
|
||||
'Bevor ein Modul auf einem Produktions-Hub installiert wird, in der CLI `fai doctor` laufen lassen. Validiert Manifest, Signaturen und deklarierte Berechtigungen gegen die Operator-Policy-Decke — keine Überraschungen beim Install.',
|
||||
ctaLabelEn: '',
|
||||
ctaLabelDe: '',
|
||||
icon: Icons.health_and_safety_outlined,
|
||||
cta: TodayCta.none,
|
||||
),
|
||||
TodayStoryData(
|
||||
badgeEn: 'BUILD ONE',
|
||||
badgeDe: 'EIGENES MODUL',
|
||||
titleEn: 'Build a module in a single Rust file',
|
||||
titleDe: 'Ein Modul in einer einzigen Rust-Datei bauen',
|
||||
bodyEn:
|
||||
'fai-module-sdk gives you a #[fai::module] macro and a Cargo target. Declare inputs / outputs in module.yaml, write the function, cargo build, drop the wasm in ~/.fai/modules. The hub picks it up at next list. Polyglot SDKs follow in Phase 1.',
|
||||
bodyDe:
|
||||
'fai-module-sdk liefert ein #[fai::module]-Makro und ein Cargo-Target. Inputs / Outputs in module.yaml deklarieren, Funktion schreiben, cargo build, das wasm in ~/.fai/modules legen. Der Hub erkennt es beim nächsten Listing. Polyglott-SDKs folgen in Phase 1.',
|
||||
ctaLabelEn: '',
|
||||
ctaLabelDe: '',
|
||||
icon: Icons.code_outlined,
|
||||
cta: TodayCta.none,
|
||||
),
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -720,6 +720,7 @@ class _ChecklistRow extends StatelessWidget {
|
|||
final bool done;
|
||||
final String title;
|
||||
final String hint;
|
||||
|
||||
/// Tap handler — takes the operator to the page where
|
||||
/// they can actually complete the item. null = no-op
|
||||
/// (e.g. AI item when System AI was already configured).
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue