diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 9d4dae093..f5be9e94c 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -16,10 +16,12 @@ env:
jobs:
framework:
runs-on: ubuntu-latest
+ timeout-minutes: 15
steps:
- uses: actions/checkout@v7
- uses: actions/setup-dotnet@v6
with:
global-json-file: global.json
- name: test
- run: dotnet test DigitalBrain.slnx -c Release
+ run: dotnet test DigitalBrain.slnx -c Release --verbosity minimal
+ timeout-minutes: 12
diff --git a/CLAUDE.md b/CLAUDE.md
index 0ab2e9359..ad0660c25 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -65,12 +65,13 @@ the live system. Tests are the regression net, not the proof.
3. Drive the real scenario through the real edge — HTTP to `digitalbrain-ui`, or the Flutter shell.
4. **Read the journal** via `digitalbrain-mcp`: `read_neuron_journal`, `read_chat_transcript`,
`list_active_neurons`. Confirm the expected synapses fired, one correlation id, right order.
-5. Cross-check Aspire — but only for what the table below says works.
+5. Cross-check Aspire against structured logs and spans.
6. Root gate for regression.
7. Only now claim, quoting what you saw.
Journals are the audit source; telemetry is a projection and never replaces them. Journals hold
-causal facts only — never arguments, prompts or secrets. Telemetry tags follow the same rule.
+causal facts only — never arguments, prompts or secrets. Production telemetry follows the same rule;
+the product AppHost opts Development into prompt and response capture for local diagnosis.
**What Aspire can actually tell you** (measured against a live AppHost after a real chat turn):
@@ -78,20 +79,20 @@ causal facts only — never arguments, prompts or secrets. Telemetry tags follow
|---|---|
| `list_resources`, `list_console_logs` | works — console output is noisy with Azurite spam |
| `digitalbrain-mcp` journals | works, and is authoritative |
-| `list_structured_logs` | **empty** |
-| `list_traces` | **no application spans** — `dotnet-cli` only |
-| GenAI spans, metrics | **do not exist** |
+| Structured logs | works — MCP invocation completion includes tool name and error state |
+| Application traces | works — ASP.NET, Orleans and kernel spans carry causal identifiers |
+| GenAI spans, metrics | works — provider, model, duration, token usage and finish reason; prompt and response content only when the AI module explicitly enables it |
-No host calls `ConfigureOpenTelemetry`, no chat client calls `UseOpenTelemetry`, and the kernel's
-`ActivitySource("DigitalBrain")` has no exporter. **This is the top open defect.** Until it lands,
-step 5 confirms health and console output only — say that, rather than implying traces were checked.
-Update this table when you fix it.
+`dotnet test os/tests/DigitalBrain.OS.Product.Tests -c Release -- -explicit only` is the live oracle.
+It starts and stops the product AppHost, drives a real Gemma4 turn and retry, confirms the durable
+transcript and correlation, checks owner-scoped active-neuron discovery, and verifies GenAI usage
+and Development message content in the exported span.
## Gates
```powershell
dotnet build DigitalBrain.slnx -c Release
-dotnet test DigitalBrain.slnx -c Release --logger "console;verbosity=minimal"
+dotnet test DigitalBrain.slnx -c Release
```
**Never `--filter` for the completion gate** — a project-scoped run has already missed a failing
diff --git a/DigitalBrain.slnx b/DigitalBrain.slnx
index b1eb47727..5baffbf4a 100644
--- a/DigitalBrain.slnx
+++ b/DigitalBrain.slnx
@@ -12,6 +12,8 @@
+
+
@@ -54,6 +56,8 @@
+
+
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 8e4ff58e2..f8624bc60 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -4,8 +4,8 @@
-
-
+
+
@@ -24,8 +24,16 @@
+
+
+
+
+
+
+
+
@@ -37,4 +45,4 @@
-
\ No newline at end of file
+
diff --git a/README.md b/README.md
index 7a4e1fede..2f07a2356 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@ await brain.SendAsync(
```
The owner-bound `IDigitalBrain` facade enters through a session; neurons call typed capabilities such
-as `ILlama32` inside the brain. The same vocabulary will later carry approved C# behaviors generated
+as `IGemma4` inside the brain. The same vocabulary will later carry approved C# behaviors generated
from natural language.
## The shape of it
@@ -22,7 +22,7 @@ from natural language.
- **A synapse is a fact** — a thin record, broadcast, no reply. **An interface method is a request** —
directed at a capability, and it replies. Both are journaled; neither is privileged.
- **Modules own vocabulary** — synapse records and neuron interfaces, resolved at compile time.
-- **Namespaces and type names are architecture** — `DigitalBrain.AI.Ollama.ILlama32` is identity, not
+- **Namespaces and type names are architecture** — `DigitalBrain.AI.Ollama.IGemma4` is identity, not
a lookup result.
- **Journals are the audit source**, recording causal facts only — never arguments, prompts or
secrets. Telemetry is a projection and never replaces them.
@@ -37,11 +37,13 @@ The plan of record. Nothing is shipped unless it says Built.
| Neuron/synapse foundation, owner-scoped client, module activation, AppHost composition, testing path | **Built** |
| Typed AI, Tasks, Google, Salesforce, Chat, Flutter, Quickstart families | **Built** |
| Flutter vertical — shell/scene vocabulary, UI HTTP/SSE edge, `WithUIEdge`/`WithFlutterHost`, headless Dart host, Windows chrome | **Built** |
+| Product shell — responsive Chat, content-safe Activity, live 3D-projected Brain topology, pulses and inspector | **Built** |
+| Product MCP surface — durable chat send/read, neuron journal observation, active-neuron discovery | **Built** |
| Time — durable one-shot `ICountdown` and its recovery tests | **Built** |
| Time — reminders, recurring interval/calendar scheduling, DST | Designed |
-| Product chrome polish, multi-principal IdP edge, journal observation on `IDigitalBrain` | Designed |
+| Multi-principal IdP edge, journal observation on `IDigitalBrain` | Designed |
| Behavior rail — proposal, approval, installation, execution, rollback | Designed |
-| Observability spine — host OpenTelemetry, instrumented chat clients, GenAI spans | **Not built — top open defect** |
+| Observability spine — host OpenTelemetry, structured logs, causal kernel spans, GenAI spans and metrics | **Built** |
`DigitalBrain.Behaviors` is a packable SDK foundation (authoring interfaces, constrained context,
manifests, artifact identities) and holds the canonical artifact codec. It is not a compiler, builder,
@@ -70,6 +72,13 @@ git clean -fdx
aspire run
```
+The explicit product suite performs a self-cleaning live Aspire proof across resource health,
+Gemma4 chat, command retry, durable journals, owner-scoped introspection, and OpenTelemetry:
+
+```powershell
+dotnet test os/tests/DigitalBrain.OS.Product.Tests -c Release -- -explicit only
+```
+
[CLAUDE.md](CLAUDE.md) is the working discipline for every agent and contributor: the gates, the
verification ladder, and the traps. A green test suite is necessary, not sufficient — it proves the
code holds, not that a behaviour works.
diff --git a/clients/digitalbrain_flutter/bin/digitalbrain_host.dart b/clients/digitalbrain_flutter/bin/digitalbrain_host.dart
index 329a4a6de..97fb459e4 100644
--- a/clients/digitalbrain_flutter/bin/digitalbrain_host.dart
+++ b/clients/digitalbrain_flutter/bin/digitalbrain_host.dart
@@ -6,7 +6,8 @@ import 'package:digitalbrain_flutter/digitalbrain_flutter.dart';
Future main(List args) async {
final shell = _arg(args, '--shell') ?? DigitalBrainHostEnv.resolveShell();
final open = _arg(args, '--open');
- final reconnectSeconds = int.tryParse(_arg(args, '--reconnect-seconds') ?? '') ?? 2;
+ final reconnectSeconds =
+ int.tryParse(_arg(args, '--reconnect-seconds') ?? '') ?? 2;
final client = DigitalBrainUiEdgeClient.fromEnvironment();
final surface = ShellSurfaceController();
diff --git a/clients/digitalbrain_flutter/lib/digitalbrain_flutter.dart b/clients/digitalbrain_flutter/lib/digitalbrain_flutter.dart
index eae8b0947..13c1e5aa7 100644
--- a/clients/digitalbrain_flutter/lib/digitalbrain_flutter.dart
+++ b/clients/digitalbrain_flutter/lib/digitalbrain_flutter.dart
@@ -1,12 +1,16 @@
-export 'package:digitalbrain_wire/digitalbrain_wire.dart' show
- ActivateControlRequest,
- ChatTurnEvent,
- OpenSceneRequest,
- SceneOpenedEvent,
- SendMessageRequest;
+export 'package:digitalbrain_wire/digitalbrain_wire.dart'
+ show
+ ActivateControlRequest,
+ BrainModule,
+ BrainNeuron,
+ BrainTopologySnapshot,
+ ChatTurnEvent,
+ OpenSceneRequest,
+ SceneOpenedEvent,
+ SendMessageRequest;
export 'src/edge_client.dart';
export 'src/host_environment.dart';
export 'src/shell_surface.dart';
export 'src/sse_chat_frames.dart';
-export 'src/sse_frames.dart';
\ No newline at end of file
+export 'src/sse_frames.dart';
diff --git a/clients/digitalbrain_flutter/lib/src/edge_client.dart b/clients/digitalbrain_flutter/lib/src/edge_client.dart
index 4c36e21d7..d818dc6a2 100644
--- a/clients/digitalbrain_flutter/lib/src/edge_client.dart
+++ b/clients/digitalbrain_flutter/lib/src/edge_client.dart
@@ -1,3 +1,4 @@
+import 'dart:async';
import 'dart:convert';
import 'package:digitalbrain_wire/digitalbrain_wire.dart';
@@ -8,11 +9,9 @@ import 'sse_chat_frames.dart';
import 'sse_frames.dart';
final class DigitalBrainUiEdgeClient {
- DigitalBrainUiEdgeClient({
- required this.baseUri,
- http.Client? httpClient,
- }) : _http = httpClient ?? http.Client(),
- _ownsClient = httpClient == null;
+ DigitalBrainUiEdgeClient({required this.baseUri, http.Client? httpClient})
+ : _http = httpClient ?? http.Client(),
+ _ownsClient = httpClient == null;
factory DigitalBrainUiEdgeClient.fromEnvironment({
http.Client? httpClient,
@@ -148,6 +147,55 @@ final class DigitalBrainUiEdgeClient {
}
}
+ Future readBrainTopology({
+ Duration requestTimeout = const Duration(seconds: 5),
+ }) async {
+ final uri = baseUri.replace(path: '/brain/topology');
+ final abort = Completer();
+ final operation = () async {
+ final request = http.AbortableRequest(
+ 'GET',
+ uri,
+ abortTrigger: abort.future,
+ );
+ final streamed = await _http.send(request);
+ return http.Response.fromStream(streamed);
+ }();
+ final response = await operation.timeout(
+ requestTimeout,
+ onTimeout: () {
+ abort.complete();
+ throw http.RequestAbortedException(uri);
+ },
+ );
+ if (response.statusCode != 200) {
+ throw StateError(
+ 'brain-topology failed: ${response.statusCode} ${response.body}',
+ );
+ }
+
+ final decoded = jsonDecode(response.body);
+ if (decoded is! Map) {
+ throw const FormatException('brain-topology response is not an object');
+ }
+
+ return BrainTopologySnapshot.fromJson(Map.from(decoded));
+ }
+
+ Stream watchBrainTopology({
+ Duration pollInterval = const Duration(seconds: 2),
+ Duration requestTimeout = const Duration(seconds: 5),
+ }) async* {
+ while (true) {
+ try {
+ yield await readBrainTopology(requestTimeout: requestTimeout);
+ } on Object catch (error, stackTrace) {
+ yield* Stream.error(error, stackTrace);
+ }
+ await Future.delayed(pollInterval);
+ }
+ }
+
void close() {
if (_ownsClient) {
_http.close();
diff --git a/clients/digitalbrain_flutter/lib/src/shell_surface.dart b/clients/digitalbrain_flutter/lib/src/shell_surface.dart
index 6b81ebb67..d1cacfa6d 100644
--- a/clients/digitalbrain_flutter/lib/src/shell_surface.dart
+++ b/clients/digitalbrain_flutter/lib/src/shell_surface.dart
@@ -26,7 +26,9 @@ final class ShellSurfaceController {
title: event.title,
sequence: event.sequence,
);
- final index = _scenes.indexWhere((scene) => scene.sceneKey == view.sceneKey);
+ final index = _scenes.indexWhere(
+ (scene) => scene.sceneKey == view.sceneKey,
+ );
if (index >= 0) {
_scenes[index] = view;
} else {
diff --git a/clients/digitalbrain_flutter/lib/src/sse_frames.dart b/clients/digitalbrain_flutter/lib/src/sse_frames.dart
index 882737293..d1123940b 100644
--- a/clients/digitalbrain_flutter/lib/src/sse_frames.dart
+++ b/clients/digitalbrain_flutter/lib/src/sse_frames.dart
@@ -55,9 +55,7 @@ final class SseSceneOpenedParser {
if (decoded is! Map) {
return null;
}
- return SceneOpenedEvent.fromJson(
- Map.from(decoded),
- );
+ return SceneOpenedEvent.fromJson(Map.from(decoded));
} on FormatException {
return null;
} on TypeError {
diff --git a/clients/digitalbrain_flutter/shell/lib/activity_screen.dart b/clients/digitalbrain_flutter/shell/lib/activity_screen.dart
new file mode 100644
index 000000000..b13d0d860
--- /dev/null
+++ b/clients/digitalbrain_flutter/shell/lib/activity_screen.dart
@@ -0,0 +1,148 @@
+import 'package:digitalbrain_flutter/digitalbrain_flutter.dart';
+import 'package:flutter/material.dart';
+
+import 'brain_theme.dart';
+
+final class ActivityScreen extends StatelessWidget {
+ const ActivityScreen({super.key, required this.turns});
+
+ final List turns;
+
+ @override
+ Widget build(BuildContext context) {
+ return ColoredBox(
+ key: const Key('activity_screen'),
+ color: BrainPalette.surface,
+ child: turns.isEmpty
+ ? const _EmptyActivity()
+ : Align(
+ alignment: Alignment.topCenter,
+ child: ConstrainedBox(
+ constraints: const BoxConstraints(maxWidth: 980),
+ child: ListView(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 32,
+ vertical: 32,
+ ),
+ children: [
+ const _ActivityHeader(),
+ const SizedBox(height: 24),
+ for (final turn in turns.reversed)
+ _ActivityEntry(turn: turn),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+final class _ActivityHeader extends StatelessWidget {
+ const _ActivityHeader();
+
+ @override
+ Widget build(BuildContext context) {
+ return const Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text('Activity', style: BrainType.heading),
+ SizedBox(height: 8),
+ Text(
+ 'Durable conversation facts, newest first. Message content stays in Chat.',
+ style: BrainType.bodyMuted,
+ ),
+ ],
+ );
+ }
+}
+
+final class _EmptyActivity extends StatelessWidget {
+ const _EmptyActivity();
+
+ @override
+ Widget build(BuildContext context) {
+ return Center(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ const Icon(
+ Icons.timeline_rounded,
+ size: 34,
+ color: BrainPalette.textMuted,
+ ),
+ const SizedBox(height: 16),
+ const Text('No activity yet.', style: BrainType.empty),
+ const SizedBox(height: 7),
+ Text(
+ 'Journal facts appear after the conversation begins.',
+ style: BrainType.body.copyWith(color: BrainPalette.textMuted),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+final class _ActivityEntry extends StatelessWidget {
+ const _ActivityEntry({required this.turn});
+
+ final ChatTurnEvent turn;
+
+ @override
+ Widget build(BuildContext context) {
+ final color = turn.fromUser ? BrainPalette.owner : BrainPalette.signal;
+
+ return Container(
+ margin: const EdgeInsets.only(bottom: 12),
+ padding: const EdgeInsets.all(18),
+ decoration: BoxDecoration(
+ color: BrainPalette.surfaceRaised,
+ borderRadius: BorderRadius.circular(14),
+ border: Border.all(color: BrainPalette.line),
+ ),
+ child: Row(
+ children: [
+ Container(
+ width: 34,
+ height: 34,
+ decoration: BoxDecoration(
+ color: color.withValues(alpha: 0.12),
+ borderRadius: BorderRadius.circular(10),
+ ),
+ child: Icon(
+ turn.fromUser
+ ? Icons.north_east_rounded
+ : Icons.south_west_rounded,
+ color: color,
+ size: 17,
+ ),
+ ),
+ const SizedBox(width: 16),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ turn.synapse,
+ style: BrainType.metaStrong.copyWith(color: color),
+ ),
+ const SizedBox(height: 6),
+ Wrap(
+ spacing: 18,
+ runSpacing: 5,
+ children: [
+ Text(
+ 'sequence ${turn.sequence.toString().padLeft(3, '0')}',
+ style: BrainType.meta,
+ ),
+ Text('command ${turn.commandId}', style: BrainType.meta),
+ ],
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/clients/digitalbrain_flutter/shell/lib/brain_screen.dart b/clients/digitalbrain_flutter/shell/lib/brain_screen.dart
new file mode 100644
index 000000000..4ba98b4c9
--- /dev/null
+++ b/clients/digitalbrain_flutter/shell/lib/brain_screen.dart
@@ -0,0 +1,434 @@
+import 'package:digitalbrain_flutter/digitalbrain_flutter.dart';
+import 'package:flutter/material.dart';
+
+import 'brain_theme.dart';
+import 'brain_topology_canvas.dart';
+
+final class BrainScreen extends StatefulWidget {
+ const BrainScreen({
+ super.key,
+ required this.chatName,
+ required this.turns,
+ this.topology,
+ this.statusMessage,
+ });
+
+ final String chatName;
+ final List turns;
+ final BrainTopologySnapshot? topology;
+ final String? statusMessage;
+
+ @override
+ State createState() => _BrainScreenState();
+}
+
+final class _BrainScreenState extends State {
+ BrainTopologySelection? _selection;
+
+ @override
+ void initState() {
+ super.initState();
+ if (widget.turns.isNotEmpty) {
+ _selection = BrainPulseSelection(widget.turns.last);
+ }
+ }
+
+ @override
+ void didUpdateWidget(covariant BrainScreen oldWidget) {
+ super.didUpdateWidget(oldWidget);
+ if (widget.chatName != oldWidget.chatName) {
+ _selection = null;
+ return;
+ }
+
+ if (widget.turns.isNotEmpty &&
+ (oldWidget.turns.isEmpty ||
+ widget.turns.last.sequence != oldWidget.turns.last.sequence ||
+ widget.turns.last.correlationId !=
+ oldWidget.turns.last.correlationId)) {
+ _selection = BrainPulseSelection(widget.turns.last);
+ return;
+ }
+
+ final topology = widget.topology;
+ _selection = switch (_selection) {
+ BrainPulseSelection(:final turn)
+ when !widget.turns.any(
+ (candidate) =>
+ candidate.sequence == turn.sequence &&
+ candidate.correlationId == turn.correlationId,
+ ) =>
+ null,
+ BrainNeuronSelection(:final neuron)
+ when topology == null ||
+ !topology.neurons.any((candidate) => candidate.id == neuron.id) =>
+ null,
+ BrainModuleSelection(:final module)
+ when topology == null ||
+ !topology.modules.any((candidate) => candidate.id == module.id) =>
+ null,
+ final selection => selection,
+ };
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final connected =
+ widget.statusMessage == null || widget.statusMessage!.isEmpty;
+ final lastSequence = widget.turns.isEmpty
+ ? '—'
+ : '${widget.turns.last.sequence}';
+ final pulse = widget.turns.lastOrNull;
+
+ return ColoredBox(
+ key: const Key('brain_screen'),
+ color: BrainPalette.surface,
+ child: Align(
+ alignment: Alignment.topCenter,
+ child: ConstrainedBox(
+ constraints: const BoxConstraints(maxWidth: 1120),
+ child: ListView(
+ padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 32),
+ children: [
+ const Text('Brain', style: BrainType.heading),
+ const SizedBox(height: 8),
+ const Text(
+ 'Live modules, owner-scoped neurons, and durable causal pulses.',
+ style: BrainType.bodyMuted,
+ ),
+ const SizedBox(height: 26),
+ Wrap(
+ spacing: 12,
+ runSpacing: 12,
+ children: [
+ _MetricCard(
+ label: 'Runtime',
+ value: connected ? 'Connected' : 'Offline',
+ accent: connected
+ ? BrainPalette.success
+ : BrainPalette.signal,
+ ),
+ _MetricCard(
+ label: 'Modules',
+ value: '${widget.topology?.modules.length ?? 0}',
+ ),
+ _MetricCard(
+ label: 'Active neurons',
+ value: '${widget.topology?.neurons.length ?? 0}',
+ ),
+ _MetricCard(label: 'Last sequence', value: lastSequence),
+ ],
+ ),
+ const SizedBox(height: 24),
+ _TopologyPanel(
+ topology: widget.topology,
+ pulse: pulse,
+ selection: _selection,
+ onSelected: (selection) =>
+ setState(() => _selection = selection),
+ ),
+ if (!connected) ...[
+ const SizedBox(height: 20),
+ _ConnectionNotice(message: widget.statusMessage!),
+ ],
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+final class _TopologyPanel extends StatelessWidget {
+ const _TopologyPanel({
+ required this.topology,
+ required this.pulse,
+ required this.selection,
+ required this.onSelected,
+ });
+
+ final BrainTopologySnapshot? topology;
+ final ChatTurnEvent? pulse;
+ final BrainTopologySelection? selection;
+ final ValueChanged onSelected;
+
+ @override
+ Widget build(BuildContext context) {
+ final snapshot = topology;
+ if (snapshot == null) {
+ return Container(
+ height: 260,
+ decoration: _panelDecoration(),
+ child: const Center(
+ child: Text('Waiting for live topology…', style: BrainType.bodyMuted),
+ ),
+ );
+ }
+
+ return LayoutBuilder(
+ builder: (context, constraints) {
+ final wide = constraints.maxWidth >= 860;
+ final canvas = Container(
+ height: 430,
+ decoration: _panelDecoration(),
+ clipBehavior: Clip.antiAlias,
+ child: BrainTopologyCanvas(
+ topology: snapshot,
+ pulse: pulse,
+ onSelected: onSelected,
+ ),
+ );
+ final explorer = _TopologyExplorer(
+ topology: snapshot,
+ selection: selection,
+ onSelected: onSelected,
+ );
+
+ if (wide) {
+ return Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Expanded(flex: 3, child: canvas),
+ const SizedBox(width: 14),
+ SizedBox(width: 320, child: explorer),
+ ],
+ );
+ }
+
+ return Column(children: [canvas, const SizedBox(height: 14), explorer]);
+ },
+ );
+ }
+}
+
+final class _TopologyExplorer extends StatelessWidget {
+ const _TopologyExplorer({
+ required this.topology,
+ required this.selection,
+ required this.onSelected,
+ });
+
+ final BrainTopologySnapshot topology;
+ final BrainTopologySelection? selection;
+ final ValueChanged onSelected;
+
+ @override
+ Widget build(BuildContext context) {
+ return Container(
+ key: const Key('brain_inspector'),
+ constraints: const BoxConstraints(minHeight: 430),
+ padding: const EdgeInsets.all(18),
+ decoration: _panelDecoration(),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const Text('INSPECTOR', style: BrainType.meta),
+ const SizedBox(height: 14),
+ _SelectionDetails(selection: selection),
+ const SizedBox(height: 20),
+ const Text('MODULES', style: BrainType.meta),
+ const SizedBox(height: 8),
+ Wrap(
+ spacing: 7,
+ runSpacing: 7,
+ children: [
+ for (var index = 0; index < topology.modules.length; index++)
+ ActionChip(
+ key: Key('topology_module_$index'),
+ label: Text(brainModuleLabel(topology.modules[index])),
+ onPressed: () =>
+ onSelected(BrainModuleSelection(topology.modules[index])),
+ ),
+ ],
+ ),
+ const SizedBox(height: 18),
+ const Text('ACTIVE NEURONS', style: BrainType.meta),
+ const SizedBox(height: 8),
+ for (var index = 0; index < topology.neurons.length; index++)
+ Padding(
+ padding: const EdgeInsets.only(bottom: 6),
+ child: TextButton(
+ key: Key('topology_neuron_$index'),
+ onPressed: () =>
+ onSelected(BrainNeuronSelection(topology.neurons[index])),
+ child: Align(
+ alignment: Alignment.centerLeft,
+ child: Text(
+ topology.neurons[index].id,
+ overflow: TextOverflow.ellipsis,
+ style: BrainType.meta,
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+final class _SelectionDetails extends StatelessWidget {
+ const _SelectionDetails({required this.selection});
+
+ final BrainTopologySelection? selection;
+
+ @override
+ Widget build(BuildContext context) {
+ return switch (selection) {
+ BrainPulseSelection(:final turn) => _PulseDetails(turn: turn),
+ BrainNeuronSelection(:final neuron) => _NeuronDetails(neuron: neuron),
+ BrainModuleSelection(:final module) => _ModuleDetails(module: module),
+ null => const Text(
+ 'Select a module or neuron. New chat turns open their causal pulse automatically.',
+ style: BrainType.bodyMuted,
+ ),
+ };
+ }
+}
+
+final class _PulseDetails extends StatelessWidget {
+ const _PulseDetails({required this.turn});
+
+ final ChatTurnEvent turn;
+
+ @override
+ Widget build(BuildContext context) {
+ return Column(
+ key: const Key('brain_pulse_details'),
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(turn.synapse, style: BrainType.cardTitle),
+ const SizedBox(height: 10),
+ _InspectorField(label: 'neuron', value: turn.neuronId),
+ _InspectorField(label: 'caller', value: turn.caller),
+ _InspectorField(label: 'correlation', value: turn.correlationId),
+ _InspectorField(label: 'command', value: turn.commandId),
+ _InspectorField(label: 'sequence', value: '${turn.sequence}'),
+ _InspectorField(
+ label: 'timestamp',
+ value: turn.timestamp.toIso8601String(),
+ ),
+ ],
+ );
+ }
+}
+
+final class _NeuronDetails extends StatelessWidget {
+ const _NeuronDetails({required this.neuron});
+
+ final BrainNeuron neuron;
+
+ @override
+ Widget build(BuildContext context) {
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(neuron.grainType, style: BrainType.cardTitle),
+ const SizedBox(height: 10),
+ _InspectorField(label: 'id', value: neuron.id),
+ _InspectorField(label: 'identity', value: neuron.identity),
+ _InspectorField(label: 'placement', value: neuron.placement),
+ ],
+ );
+ }
+}
+
+final class _ModuleDetails extends StatelessWidget {
+ const _ModuleDetails({required this.module});
+
+ final BrainModule module;
+
+ @override
+ Widget build(BuildContext context) {
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(brainModuleLabel(module), style: BrainType.cardTitle),
+ const SizedBox(height: 10),
+ _InspectorField(label: 'module id', value: module.id),
+ ],
+ );
+ }
+}
+
+final class _InspectorField extends StatelessWidget {
+ const _InspectorField({required this.label, required this.value});
+
+ final String label;
+ final String value;
+
+ @override
+ Widget build(BuildContext context) {
+ return Padding(
+ padding: const EdgeInsets.only(bottom: 7),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(label, style: BrainType.meta),
+ const SizedBox(height: 2),
+ SelectableText(value, style: BrainType.metaStrong),
+ ],
+ ),
+ );
+ }
+}
+
+final class _MetricCard extends StatelessWidget {
+ const _MetricCard({
+ required this.label,
+ required this.value,
+ this.accent = BrainPalette.textPrimary,
+ });
+
+ final String label;
+ final String value;
+ final Color accent;
+
+ @override
+ Widget build(BuildContext context) {
+ return Container(
+ width: 190,
+ padding: const EdgeInsets.all(16),
+ decoration: _panelDecoration(),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(label, style: BrainType.meta),
+ const SizedBox(height: 9),
+ Text(
+ value,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: BrainType.metric.copyWith(color: accent),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+final class _ConnectionNotice extends StatelessWidget {
+ const _ConnectionNotice({required this.message});
+
+ final String message;
+
+ @override
+ Widget build(BuildContext context) {
+ return Container(
+ padding: const EdgeInsets.all(16),
+ decoration: BoxDecoration(
+ color: BrainPalette.signal.withValues(alpha: 0.08),
+ borderRadius: BorderRadius.circular(12),
+ border: Border.all(color: BrainPalette.signal.withValues(alpha: 0.25)),
+ ),
+ child: Text(message, style: BrainType.bodyMuted),
+ );
+ }
+}
+
+BoxDecoration _panelDecoration({Color color = BrainPalette.surfaceRaised}) =>
+ BoxDecoration(
+ color: color,
+ borderRadius: BorderRadius.circular(14),
+ border: Border.all(color: BrainPalette.line),
+ );
diff --git a/clients/digitalbrain_flutter/shell/lib/brain_theme.dart b/clients/digitalbrain_flutter/shell/lib/brain_theme.dart
index afd811c23..4150cc939 100644
--- a/clients/digitalbrain_flutter/shell/lib/brain_theme.dart
+++ b/clients/digitalbrain_flutter/shell/lib/brain_theme.dart
@@ -1,24 +1,72 @@
import 'package:flutter/material.dart';
-/// The conversation is a journal you can talk to, so the surface reads as an
-/// instrument rather than a messenger: deep slate-indigo, two voices separated
-/// by colour temperature, and a monospace rail carrying real journal sequence.
abstract final class BrainPalette {
- static const surface = Color(0xFF14161D);
- static const surfaceRaised = Color(0xFF1C1F29);
- static const line = Color(0xFF2A2E3B);
- static const textPrimary = Color(0xFFE6E8EF);
- static const textMuted = Color(0xFF8A90A3);
+ static const navigation = Color(0xFF101219);
+ static const surface = Color(0xFF141720);
+ static const surfaceRaised = Color(0xFF1B1F2A);
+ static const surfaceSunken = Color(0xFF11141B);
+ static const line = Color(0xFF292E3B);
+ static const lineStrong = Color(0xFF363C4C);
+ static const textPrimary = Color(0xFFE9EBF2);
+ static const textMuted = Color(0xFF969CAF);
+ static const textFaint = Color(0xFF5E6474);
+ static const signal = Color(0xFFE09261);
+ static const owner = Color(0xFF7B9BE3);
+ static const success = Color(0xFF65C5A0);
+}
- /// The brain answering.
- static const signal = Color(0xFFD98A5B);
+abstract final class BrainTheme {
+ static ThemeData dark() {
+ final scheme = ColorScheme.fromSeed(
+ seedColor: BrainPalette.signal,
+ brightness: Brightness.dark,
+ surface: BrainPalette.surface,
+ );
- /// The owner speaking.
- static const owner = Color(0xFF6E8FD4);
+ return ThemeData(
+ useMaterial3: true,
+ brightness: Brightness.dark,
+ scaffoldBackgroundColor: BrainPalette.surface,
+ colorScheme: scheme.copyWith(
+ primary: BrainPalette.signal,
+ secondary: BrainPalette.owner,
+ surface: BrainPalette.surface,
+ ),
+ dividerColor: BrainPalette.line,
+ navigationRailTheme: NavigationRailThemeData(
+ backgroundColor: BrainPalette.navigation,
+ indicatorColor: BrainPalette.signal.withValues(alpha: 0.14),
+ selectedIconTheme: const IconThemeData(
+ color: BrainPalette.signal,
+ size: 21,
+ ),
+ unselectedIconTheme: const IconThemeData(
+ color: BrainPalette.textMuted,
+ size: 20,
+ ),
+ selectedLabelTextStyle: BrainType.metaStrong.copyWith(
+ color: BrainPalette.signal,
+ ),
+ unselectedLabelTextStyle: BrainType.meta,
+ ),
+ navigationBarTheme: NavigationBarThemeData(
+ backgroundColor: BrainPalette.navigation,
+ indicatorColor: BrainPalette.signal.withValues(alpha: 0.14),
+ labelTextStyle: WidgetStateProperty.resolveWith(
+ (states) => states.contains(WidgetState.selected)
+ ? BrainType.metaStrong.copyWith(color: BrainPalette.signal)
+ : BrainType.meta,
+ ),
+ ),
+ tooltipTheme: const TooltipThemeData(
+ decoration: BoxDecoration(color: BrainPalette.surfaceRaised),
+ textStyle: BrainType.meta,
+ ),
+ );
+ }
}
abstract final class BrainType {
- /// Windows ships Cascadia; the fallbacks keep the rail monospace elsewhere.
static const monoFamily = 'Cascadia Mono';
static const monoFallback = ['Consolas', 'Menlo', 'monospace'];
@@ -34,6 +82,33 @@ abstract final class BrainType {
color: BrainPalette.textPrimary,
);
+ static const heading = TextStyle(
+ fontFamily: bodyFamily,
+ fontFamilyFallback: bodyFallback,
+ fontSize: 28,
+ fontWeight: FontWeight.w600,
+ letterSpacing: -0.8,
+ color: BrainPalette.textPrimary,
+ );
+
+ static const cardTitle = TextStyle(
+ fontFamily: bodyFamily,
+ fontFamilyFallback: bodyFallback,
+ fontSize: 15,
+ fontWeight: FontWeight.w600,
+ letterSpacing: -0.15,
+ color: BrainPalette.textPrimary,
+ );
+
+ static const metric = TextStyle(
+ fontFamily: bodyFamily,
+ fontFamilyFallback: bodyFallback,
+ fontSize: 17,
+ fontWeight: FontWeight.w600,
+ letterSpacing: -0.2,
+ color: BrainPalette.textPrimary,
+ );
+
static const body = TextStyle(
fontFamily: bodyFamily,
fontFamilyFallback: bodyFallback,
@@ -42,21 +117,38 @@ abstract final class BrainType {
color: BrainPalette.textPrimary,
);
+ static const bodyMuted = TextStyle(
+ fontFamily: bodyFamily,
+ fontFamilyFallback: bodyFallback,
+ fontSize: 14,
+ height: 1.5,
+ color: BrainPalette.textMuted,
+ );
+
static const meta = TextStyle(
fontFamily: monoFamily,
fontFamilyFallback: monoFallback,
fontSize: 11,
fontWeight: FontWeight.w500,
- letterSpacing: 0.5,
+ letterSpacing: 0.45,
color: BrainPalette.textMuted,
);
+ static const metaStrong = TextStyle(
+ fontFamily: monoFamily,
+ fontFamilyFallback: monoFallback,
+ fontSize: 11,
+ fontWeight: FontWeight.w600,
+ letterSpacing: 0.45,
+ color: BrainPalette.textPrimary,
+ );
+
static const empty = TextStyle(
fontFamily: bodyFamily,
fontFamilyFallback: bodyFallback,
- fontSize: 17,
- fontWeight: FontWeight.w500,
- letterSpacing: -0.2,
+ fontSize: 18,
+ fontWeight: FontWeight.w600,
+ letterSpacing: -0.3,
color: BrainPalette.textPrimary,
);
}
diff --git a/clients/digitalbrain_flutter/shell/lib/brain_topology_canvas.dart b/clients/digitalbrain_flutter/shell/lib/brain_topology_canvas.dart
new file mode 100644
index 000000000..d08694075
--- /dev/null
+++ b/clients/digitalbrain_flutter/shell/lib/brain_topology_canvas.dart
@@ -0,0 +1,433 @@
+import 'dart:math' as math;
+
+import 'package:digitalbrain_flutter/digitalbrain_flutter.dart';
+import 'package:flutter/material.dart';
+
+import 'brain_theme.dart';
+
+sealed class BrainTopologySelection {
+ const BrainTopologySelection();
+}
+
+final class BrainModuleSelection extends BrainTopologySelection {
+ const BrainModuleSelection(this.module);
+
+ final BrainModule module;
+}
+
+final class BrainNeuronSelection extends BrainTopologySelection {
+ const BrainNeuronSelection(this.neuron);
+
+ final BrainNeuron neuron;
+}
+
+final class BrainPulseSelection extends BrainTopologySelection {
+ const BrainPulseSelection(this.turn);
+
+ final ChatTurnEvent turn;
+}
+
+final class BrainTopologyCanvas extends StatefulWidget {
+ const BrainTopologyCanvas({
+ super.key,
+ required this.topology,
+ required this.onSelected,
+ this.pulse,
+ });
+
+ final BrainTopologySnapshot topology;
+ final ChatTurnEvent? pulse;
+ final ValueChanged onSelected;
+
+ @override
+ State createState() => _BrainTopologyCanvasState();
+}
+
+final class _BrainTopologyCanvasState extends State
+ with SingleTickerProviderStateMixin {
+ late final AnimationController _pulse;
+ double _rotationX = -0.18;
+ double _rotationY = 0.42;
+
+ @override
+ void initState() {
+ super.initState();
+ _pulse = AnimationController(
+ vsync: this,
+ duration: const Duration(milliseconds: 1100),
+ );
+ if (_hasPulseTarget(widget.topology, widget.pulse)) {
+ _pulse.forward();
+ }
+ }
+
+ @override
+ void didUpdateWidget(covariant BrainTopologyCanvas oldWidget) {
+ super.didUpdateWidget(oldWidget);
+ final pulseChanged =
+ widget.pulse?.sequence != oldWidget.pulse?.sequence ||
+ widget.pulse?.correlationId != oldWidget.pulse?.correlationId;
+ final hadTarget = _hasPulseTarget(oldWidget.topology, oldWidget.pulse);
+ final hasTarget = _hasPulseTarget(widget.topology, widget.pulse);
+ if (!hasTarget) {
+ if (pulseChanged || hadTarget) {
+ _pulse.reset();
+ }
+ } else if (pulseChanged || !hadTarget) {
+ _pulse.forward(from: 0);
+ }
+ }
+
+ @override
+ void dispose() {
+ _pulse.dispose();
+ super.dispose();
+ }
+
+ void _rotate(DragUpdateDetails details) {
+ setState(() {
+ _rotationY += details.delta.dx * 0.008;
+ _rotationX = (_rotationX + details.delta.dy * 0.008).clamp(-1.0, 1.0);
+ });
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final disableAnimations = MediaQuery.disableAnimationsOf(context);
+ final hasPulseTarget = _hasPulseTarget(widget.topology, widget.pulse);
+ final localPulse =
+ hasPulseTarget && widget.pulse!.caller == widget.pulse!.neuronId;
+ final edgePulse =
+ hasPulseTarget &&
+ !localPulse &&
+ _hasNode(widget.topology, widget.pulse!.caller);
+
+ return Semantics(
+ key: const Key('brain_topology_canvas'),
+ label:
+ 'Interactive three-dimensional DigitalBrain topology. Drag to rotate; use the topology list to inspect accessible node details.',
+ image: true,
+ child: LayoutBuilder(
+ builder: (context, constraints) {
+ final size = Size(constraints.maxWidth, constraints.maxHeight);
+ return AnimatedBuilder(
+ animation: _pulse,
+ builder: (context, _) {
+ final projected = _projectTopology(
+ widget.topology,
+ size,
+ _rotationX,
+ _rotationY,
+ );
+ final pulseValue = disableAnimations ? 1.0 : _pulse.value;
+
+ return GestureDetector(
+ behavior: HitTestBehavior.opaque,
+ onPanUpdate: _rotate,
+ onTapUp: (details) {
+ final selected = _hitTest(projected, details.localPosition);
+ if (selected != null) {
+ widget.onSelected(selected.selection);
+ }
+ },
+ child: Stack(
+ fit: StackFit.expand,
+ children: [
+ CustomPaint(
+ painter: _TopologyPainter(
+ nodes: projected,
+ pulse: widget.pulse,
+ pulseValue: pulseValue,
+ ),
+ ),
+ if (hasPulseTarget)
+ const IgnorePointer(key: Key('brain_pulse')),
+ if (localPulse)
+ const IgnorePointer(key: Key('brain_local_pulse')),
+ if (edgePulse)
+ const IgnorePointer(key: Key('brain_edge_pulse')),
+ ],
+ ),
+ );
+ },
+ );
+ },
+ ),
+ );
+ }
+}
+
+bool _hasPulseTarget(BrainTopologySnapshot topology, ChatTurnEvent? pulse) =>
+ pulse != null &&
+ topology.neurons.any((neuron) => neuron.id == pulse.neuronId);
+
+bool _hasNode(BrainTopologySnapshot topology, String id) =>
+ topology.neurons.any((neuron) => neuron.id == id) ||
+ topology.modules.any((module) => module.id == id);
+
+final class _GraphNode {
+ const _GraphNode({
+ required this.id,
+ required this.label,
+ required this.module,
+ required this.selection,
+ required this.x,
+ required this.y,
+ required this.z,
+ });
+
+ final String id;
+ final String label;
+ final bool module;
+ final BrainTopologySelection selection;
+ final double x;
+ final double y;
+ final double z;
+}
+
+final class _ProjectedNode {
+ const _ProjectedNode({
+ required this.node,
+ required this.center,
+ required this.radius,
+ required this.depth,
+ });
+
+ final _GraphNode node;
+ final Offset center;
+ final double radius;
+ final double depth;
+
+ BrainTopologySelection get selection => node.selection;
+}
+
+List<_ProjectedNode> _projectTopology(
+ BrainTopologySnapshot topology,
+ Size size,
+ double rotationX,
+ double rotationY,
+) {
+ final graph = <_GraphNode>[
+ ..._placeModules(topology.modules),
+ ..._placeNeurons(topology.neurons),
+ ];
+ final base = math.min(size.width, size.height) * 0.36;
+ final center = Offset(size.width * 0.5, size.height * 0.51);
+ final cosY = math.cos(rotationY);
+ final sinY = math.sin(rotationY);
+ final cosX = math.cos(rotationX);
+ final sinX = math.sin(rotationX);
+
+ final projected = <_ProjectedNode>[];
+ for (final node in graph) {
+ final xY = node.x * cosY + node.z * sinY;
+ final zY = -node.x * sinY + node.z * cosY;
+ final yX = node.y * cosX - zY * sinX;
+ final zX = node.y * sinX + zY * cosX;
+ final perspective = 1.0 / (1.85 - zX * 0.36);
+ final radius = (node.module ? 10.0 : 6.0) * (0.72 + perspective);
+
+ projected.add(
+ _ProjectedNode(
+ node: node,
+ center: Offset(
+ center.dx + xY * base * perspective,
+ center.dy + yX * base * perspective,
+ ),
+ radius: radius,
+ depth: zX,
+ ),
+ );
+ }
+
+ projected.sort((a, b) => a.depth.compareTo(b.depth));
+ return projected;
+}
+
+Iterable<_GraphNode> _placeModules(List modules) sync* {
+ for (var index = 0; index < modules.length; index++) {
+ final position = _spherePosition(index, modules.length, 0.88, 0);
+ final module = modules[index];
+ yield _GraphNode(
+ id: module.id,
+ label: brainModuleLabel(module),
+ module: true,
+ selection: BrainModuleSelection(module),
+ x: position.x,
+ y: position.y,
+ z: position.z,
+ );
+ }
+}
+
+Iterable<_GraphNode> _placeNeurons(List neurons) sync* {
+ for (var index = 0; index < neurons.length; index++) {
+ final position = _spherePosition(index, neurons.length, 0.62, 1.3);
+ final neuron = neurons[index];
+ yield _GraphNode(
+ id: neuron.id,
+ label: neuron.grainType,
+ module: false,
+ selection: BrainNeuronSelection(neuron),
+ x: position.x,
+ y: position.y,
+ z: position.z,
+ );
+ }
+}
+
+({double x, double y, double z}) _spherePosition(
+ int index,
+ int count,
+ double radius,
+ double phase,
+) {
+ if (count <= 1) {
+ return (x: 0, y: 0, z: radius);
+ }
+ final y = 1 - (2 * (index + 0.5) / count);
+ final ring = math.sqrt(math.max(0, 1 - y * y));
+ final theta = index * math.pi * (3 - math.sqrt(5)) + phase;
+ return (
+ x: math.cos(theta) * ring * radius,
+ y: y * radius,
+ z: math.sin(theta) * ring * radius,
+ );
+}
+
+_ProjectedNode? _hitTest(List<_ProjectedNode> nodes, Offset position) {
+ for (final node in nodes.reversed) {
+ if ((node.center - position).distance <= node.radius + 8) {
+ return node;
+ }
+ }
+ return null;
+}
+
+String brainModuleLabel(BrainModule module) {
+ final type = module.id.split('.').last;
+ return type.endsWith('Module')
+ ? type.substring(0, type.length - 'Module'.length)
+ : type;
+}
+
+final class _TopologyPainter extends CustomPainter {
+ const _TopologyPainter({
+ required this.nodes,
+ required this.pulse,
+ required this.pulseValue,
+ });
+
+ final List<_ProjectedNode> nodes;
+ final ChatTurnEvent? pulse;
+ final double pulseValue;
+
+ @override
+ void paint(Canvas canvas, Size size) {
+ final center = Offset(size.width * 0.5, size.height * 0.51);
+ final hull = Paint()
+ ..style = PaintingStyle.stroke
+ ..strokeWidth = 1
+ ..color = BrainPalette.lineStrong.withValues(alpha: 0.55);
+ final radius = math.min(size.width, size.height) * 0.34;
+ canvas.drawOval(
+ Rect.fromCenter(
+ center: center,
+ width: radius * 2.15,
+ height: radius * 1.55,
+ ),
+ hull,
+ );
+ canvas.drawOval(
+ Rect.fromCenter(
+ center: center,
+ width: radius * 1.35,
+ height: radius * 2.05,
+ ),
+ hull..color = BrainPalette.line.withValues(alpha: 0.5),
+ );
+
+ final pulseTarget = pulse == null
+ ? null
+ : nodes.where((node) => node.node.id == pulse!.neuronId).firstOrNull;
+ final pulseSource = pulse == null
+ ? null
+ : nodes.where((node) => node.node.id == pulse!.caller).firstOrNull;
+ if (pulseTarget != null) {
+ final wave = math.sin(pulseValue * math.pi).abs();
+ if (pulseSource != null && pulseSource.node.id != pulseTarget.node.id) {
+ final path = Path()
+ ..moveTo(pulseSource.center.dx, pulseSource.center.dy)
+ ..quadraticBezierTo(
+ center.dx,
+ center.dy - radius * 0.45,
+ pulseTarget.center.dx,
+ pulseTarget.center.dy,
+ );
+ canvas.drawPath(
+ path,
+ Paint()
+ ..style = PaintingStyle.stroke
+ ..strokeWidth = 2.4
+ ..color = BrainPalette.signal.withValues(alpha: 0.25 + wave * 0.7),
+ );
+ }
+ canvas.drawCircle(
+ pulseTarget.center,
+ pulseTarget.radius + 8 + wave * 16,
+ Paint()
+ ..style = PaintingStyle.stroke
+ ..strokeWidth = 2
+ ..color = BrainPalette.signal.withValues(alpha: 0.7 - wave * 0.3),
+ );
+ }
+
+ for (final projected in nodes) {
+ final node = projected.node;
+ final color = node.module ? BrainPalette.signal : BrainPalette.owner;
+ final depthAlpha = (0.5 + (projected.depth + 1) * 0.22).clamp(0.35, 1.0);
+ canvas.drawCircle(
+ projected.center,
+ projected.radius * 1.8,
+ Paint()
+ ..color = color.withValues(alpha: 0.06 * depthAlpha)
+ ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 8),
+ );
+ canvas.drawCircle(
+ projected.center,
+ projected.radius,
+ Paint()..color = color.withValues(alpha: depthAlpha),
+ );
+ canvas.drawCircle(
+ projected.center,
+ projected.radius,
+ Paint()
+ ..style = PaintingStyle.stroke
+ ..strokeWidth = 1
+ ..color = BrainPalette.textPrimary.withValues(alpha: 0.3),
+ );
+
+ if (node.module || node.id == pulse?.neuronId) {
+ final text = TextPainter(
+ text: TextSpan(
+ text: node.label,
+ style: BrainType.meta.copyWith(
+ color: BrainPalette.textPrimary.withValues(alpha: depthAlpha),
+ ),
+ ),
+ textDirection: TextDirection.ltr,
+ )..layout(maxWidth: 120);
+ text.paint(
+ canvas,
+ projected.center + Offset(-text.width / 2, projected.radius + 7),
+ );
+ }
+ }
+ }
+
+ @override
+ bool shouldRepaint(covariant _TopologyPainter oldDelegate) =>
+ oldDelegate.nodes != nodes ||
+ oldDelegate.pulse != pulse ||
+ oldDelegate.pulseValue != pulseValue;
+}
diff --git a/clients/digitalbrain_flutter/shell/lib/chat_screen.dart b/clients/digitalbrain_flutter/shell/lib/chat_screen.dart
index 0fa71573c..bb1cd9c3c 100644
--- a/clients/digitalbrain_flutter/shell/lib/chat_screen.dart
+++ b/clients/digitalbrain_flutter/shell/lib/chat_screen.dart
@@ -4,6 +4,8 @@ import 'package:digitalbrain_flutter/digitalbrain_flutter.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
+import 'activity_screen.dart';
+import 'brain_screen.dart';
import 'brain_theme.dart';
typedef SendMessage = Future Function(String text);
@@ -13,12 +15,14 @@ final class BrainChatApp extends StatelessWidget {
super.key,
required this.chatName,
this.turns,
+ this.topology,
this.onSend,
this.statusMessage,
});
final String chatName;
final Stream? turns;
+ final Stream? topology;
final SendMessage? onSend;
final String? statusMessage;
@@ -27,19 +31,11 @@ final class BrainChatApp extends StatelessWidget {
return MaterialApp(
title: 'DigitalBrain',
debugShowCheckedModeBanner: false,
- theme: ThemeData(
- useMaterial3: true,
- brightness: Brightness.dark,
- scaffoldBackgroundColor: BrainPalette.surface,
- colorScheme: ColorScheme.fromSeed(
- seedColor: BrainPalette.signal,
- brightness: Brightness.dark,
- surface: BrainPalette.surface,
- ),
- ),
- home: BrainChatScreen(
+ theme: BrainTheme.dark(),
+ home: _BrainWorkspace(
chatName: chatName,
turns: turns,
+ topology: topology,
onSend: onSend,
statusMessage: statusMessage,
),
@@ -47,64 +43,374 @@ final class BrainChatApp extends StatelessWidget {
}
}
-final class BrainChatScreen extends StatefulWidget {
- const BrainChatScreen({
- super.key,
+final class _BrainWorkspace extends StatefulWidget {
+ const _BrainWorkspace({
required this.chatName,
this.turns,
+ this.topology,
this.onSend,
this.statusMessage,
});
final String chatName;
final Stream? turns;
+ final Stream? topology;
final SendMessage? onSend;
final String? statusMessage;
@override
- State createState() => _BrainChatScreenState();
+ State<_BrainWorkspace> createState() => _BrainWorkspaceState();
}
-final class _BrainChatScreenState extends State {
+final class _BrainWorkspaceState extends State<_BrainWorkspace> {
+ static const _compactBreakpoint = 720.0;
+
final _turns = [];
final _seen = {};
- final _composer = TextEditingController();
- final _composerFocus = FocusNode();
- final _scroll = ScrollController();
-
StreamSubscription? _subscription;
- bool _awaitingBrain = false;
- String? _failure;
+ StreamSubscription? _topologySubscription;
+ BrainTopologySnapshot? _topology;
+ int _destination = 0;
+ String? _turnFailure;
+ String? _topologyFailure;
@override
void initState() {
super.initState();
_listen(widget.turns);
+ _listenTopology(widget.topology);
}
@override
- void didUpdateWidget(covariant BrainChatScreen oldWidget) {
+ void didUpdateWidget(covariant _BrainWorkspace oldWidget) {
super.didUpdateWidget(oldWidget);
+ if (oldWidget.chatName != widget.chatName) {
+ _turns.clear();
+ _seen.clear();
+ }
if (!identical(oldWidget.turns, widget.turns)) {
- _subscription?.cancel();
+ unawaited(_subscription?.cancel());
_listen(widget.turns);
}
+ if (!identical(oldWidget.topology, widget.topology)) {
+ unawaited(_topologySubscription?.cancel());
+ _listenTopology(widget.topology);
+ }
+ }
+
+ void _listenTopology(Stream? topology) {
+ _topologySubscription = topology?.listen(
+ (snapshot) {
+ if (mounted) {
+ setState(() {
+ _topology = snapshot;
+ _topologyFailure = null;
+ });
+ }
+ },
+ onError: (Object error) {
+ if (mounted) {
+ setState(() {
+ _topology = null;
+ _topologyFailure = '$error';
+ });
+ }
+ },
+ );
}
void _listen(Stream? turns) {
- _subscription = turns?.listen((turn) {
- if (!mounted || !_seen.add(turn.sequence)) {
- return;
- }
- setState(() {
- _turns.add(turn);
- _turns.sort((a, b) => a.sequence.compareTo(b.sequence));
- if (!turn.fromUser) {
- _awaitingBrain = false;
+ _turnFailure = null;
+ _subscription = turns?.listen(
+ (turn) {
+ if (!mounted || !_seen.add(turn.sequence)) {
+ return;
}
- });
+ setState(() {
+ _turns.add(turn);
+ _turns.sort((a, b) => a.sequence.compareTo(b.sequence));
+ });
+ },
+ onError: (Object error) {
+ if (mounted) {
+ setState(() => _turnFailure = '$error');
+ }
+ },
+ );
+ }
+
+ String? get _statusMessage =>
+ widget.statusMessage ?? _turnFailure ?? _topologyFailure;
+
+ void _selectDestination(int index) {
+ if (_destination != index) {
+ setState(() => _destination = index);
+ }
+ }
+
+ @override
+ void dispose() {
+ unawaited(_subscription?.cancel());
+ unawaited(_topologySubscription?.cancel());
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final projectedTurns = List.unmodifiable(_turns);
+ final pages = [
+ BrainChatScreen(
+ chatName: widget.chatName,
+ turns: projectedTurns,
+ onSend: widget.onSend,
+ ),
+ ActivityScreen(turns: projectedTurns),
+ BrainScreen(
+ chatName: widget.chatName,
+ turns: projectedTurns,
+ topology: _topology,
+ statusMessage: _statusMessage,
+ ),
+ ];
+
+ return LayoutBuilder(
+ builder: (context, constraints) {
+ final compact = constraints.maxWidth < _compactBreakpoint;
+ final content = Column(
+ children: [
+ _StatusBar(
+ chatName: widget.chatName,
+ section: _sectionName(_destination),
+ message: _statusMessage,
+ ),
+ Expanded(
+ child: IndexedStack(index: _destination, children: pages),
+ ),
+ ],
+ );
+
+ return Scaffold(
+ body: compact
+ ? content
+ : Row(
+ children: [
+ _WorkspaceRail(
+ selectedIndex: _destination,
+ onSelected: _selectDestination,
+ ),
+ const VerticalDivider(width: 1, thickness: 1),
+ Expanded(child: content),
+ ],
+ ),
+ bottomNavigationBar: compact
+ ? _WorkspaceNavigationBar(
+ selectedIndex: _destination,
+ onSelected: _selectDestination,
+ )
+ : null,
+ );
+ },
+ );
+ }
+}
+
+String _sectionName(int index) => switch (index) {
+ 0 => 'Chat',
+ 1 => 'Activity',
+ _ => 'Brain',
+};
+
+final class _WorkspaceRail extends StatelessWidget {
+ const _WorkspaceRail({required this.selectedIndex, required this.onSelected});
+
+ final int selectedIndex;
+ final ValueChanged onSelected;
+
+ @override
+ Widget build(BuildContext context) {
+ return NavigationRail(
+ backgroundColor: BrainPalette.navigation,
+ minWidth: 88,
+ groupAlignment: -0.78,
+ labelType: NavigationRailLabelType.all,
+ selectedIndex: selectedIndex,
+ onDestinationSelected: onSelected,
+ leading: const Padding(
+ padding: EdgeInsets.only(top: 10, bottom: 28),
+ child: _BrainMark(),
+ ),
+ destinations: _destinations(),
+ );
+ }
+}
+
+final class _WorkspaceNavigationBar extends StatelessWidget {
+ const _WorkspaceNavigationBar({
+ required this.selectedIndex,
+ required this.onSelected,
+ });
+
+ final int selectedIndex;
+ final ValueChanged onSelected;
+
+ @override
+ Widget build(BuildContext context) {
+ return NavigationBar(
+ selectedIndex: selectedIndex,
+ onDestinationSelected: onSelected,
+ destinations: const [
+ NavigationDestination(
+ icon: Icon(Icons.forum_outlined, key: Key('destination_chat')),
+ selectedIcon: Icon(Icons.forum, key: Key('destination_chat')),
+ label: 'Chat',
+ ),
+ NavigationDestination(
+ icon: Icon(Icons.timeline_outlined, key: Key('destination_activity')),
+ selectedIcon: Icon(Icons.timeline, key: Key('destination_activity')),
+ label: 'Activity',
+ ),
+ NavigationDestination(
+ icon: Icon(Icons.hub_outlined, key: Key('destination_brain')),
+ selectedIcon: Icon(Icons.hub, key: Key('destination_brain')),
+ label: 'Brain',
+ ),
+ ],
+ );
+ }
+}
+
+List _destinations() => const [
+ NavigationRailDestination(
+ icon: Icon(Icons.forum_outlined, key: Key('destination_chat')),
+ selectedIcon: Icon(Icons.forum, key: Key('destination_chat')),
+ label: Text('Chat'),
+ ),
+ NavigationRailDestination(
+ icon: Icon(Icons.timeline_outlined, key: Key('destination_activity')),
+ selectedIcon: Icon(Icons.timeline, key: Key('destination_activity')),
+ label: Text('Activity'),
+ ),
+ NavigationRailDestination(
+ icon: Icon(Icons.hub_outlined, key: Key('destination_brain')),
+ selectedIcon: Icon(Icons.hub, key: Key('destination_brain')),
+ label: Text('Brain'),
+ ),
+];
+
+final class _BrainMark extends StatelessWidget {
+ const _BrainMark();
+
+ @override
+ Widget build(BuildContext context) {
+ return Container(
+ width: 36,
+ height: 36,
+ decoration: BoxDecoration(
+ color: BrainPalette.signal.withValues(alpha: 0.14),
+ borderRadius: BorderRadius.circular(12),
+ border: Border.all(color: BrainPalette.signal.withValues(alpha: 0.4)),
+ ),
+ child: const Icon(
+ Icons.graphic_eq_rounded,
+ color: BrainPalette.signal,
+ size: 20,
+ ),
+ );
+ }
+}
+
+final class _StatusBar extends StatelessWidget {
+ const _StatusBar({
+ required this.chatName,
+ required this.section,
+ this.message,
+ });
+
+ final String chatName;
+ final String section;
+ final String? message;
+
+ @override
+ Widget build(BuildContext context) {
+ final offline = message != null && message!.isNotEmpty;
+
+ return Container(
+ height: 58,
+ padding: const EdgeInsets.symmetric(horizontal: 24),
+ decoration: const BoxDecoration(
+ color: BrainPalette.surfaceRaised,
+ border: Border(bottom: BorderSide(color: BrainPalette.line)),
+ ),
+ child: Row(
+ children: [
+ const Text('DigitalBrain', style: BrainType.title),
+ const SizedBox(width: 10),
+ Container(width: 1, height: 16, color: BrainPalette.lineStrong),
+ const SizedBox(width: 10),
+ Text(section, style: BrainType.metaStrong),
+ const Spacer(),
+ Text('chat:$chatName', style: BrainType.meta),
+ const SizedBox(width: 14),
+ Container(
+ width: 7,
+ height: 7,
+ decoration: BoxDecoration(
+ shape: BoxShape.circle,
+ color: offline ? BrainPalette.signal : BrainPalette.success,
+ boxShadow: [
+ BoxShadow(
+ color: (offline ? BrainPalette.signal : BrainPalette.success)
+ .withValues(alpha: 0.35),
+ blurRadius: 8,
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(width: 8),
+ Text(offline ? 'not connected' : 'connected', style: BrainType.meta),
+ ],
+ ),
+ );
+ }
+}
+
+final class BrainChatScreen extends StatefulWidget {
+ const BrainChatScreen({
+ super.key,
+ required this.chatName,
+ required this.turns,
+ this.onSend,
+ });
+
+ final String chatName;
+ final List turns;
+ final SendMessage? onSend;
+
+ @override
+ State createState() => _BrainChatScreenState();
+}
+
+final class _BrainChatScreenState extends State {
+ final _composer = TextEditingController();
+ final _composerFocus = FocusNode();
+ final _scroll = ScrollController();
+
+ bool _awaitingBrain = false;
+ int _awaitingAfterSequence = 0;
+ String? _failure;
+
+ @override
+ void didUpdateWidget(covariant BrainChatScreen oldWidget) {
+ super.didUpdateWidget(oldWidget);
+ if (widget.turns.length != oldWidget.turns.length) {
+ if (_awaitingBrain &&
+ widget.turns.any(
+ (turn) => !turn.fromUser && turn.sequence > _awaitingAfterSequence,
+ )) {
+ setState(() => _awaitingBrain = false);
+ }
_scrollToLatest();
- });
+ }
}
void _scrollToLatest() {
@@ -130,6 +436,9 @@ final class _BrainChatScreenState extends State {
_composer.clear();
setState(() {
_awaitingBrain = true;
+ _awaitingAfterSequence = widget.turns.isEmpty
+ ? 0
+ : widget.turns.last.sequence;
_failure = null;
});
@@ -148,7 +457,6 @@ final class _BrainChatScreenState extends State {
@override
void dispose() {
- _subscription?.cancel();
_composer.dispose();
_composerFocus.dispose();
_scroll.dispose();
@@ -157,15 +465,15 @@ final class _BrainChatScreenState extends State {
@override
Widget build(BuildContext context) {
- return Scaffold(
- body: Column(
+ return ColoredBox(
+ color: BrainPalette.surface,
+ child: Column(
children: [
- _StatusBar(chatName: widget.chatName, message: widget.statusMessage),
Expanded(
- child: _turns.isEmpty && !_awaitingBrain
+ child: widget.turns.isEmpty && !_awaitingBrain
? const _EmptyJournal()
: _Journal(
- turns: _turns,
+ turns: widget.turns,
awaitingBrain: _awaitingBrain,
controller: _scroll,
),
@@ -183,45 +491,6 @@ final class _BrainChatScreenState extends State {
}
}
-final class _StatusBar extends StatelessWidget {
- const _StatusBar({required this.chatName, this.message});
-
- final String chatName;
- final String? message;
-
- @override
- Widget build(BuildContext context) {
- final offline = message != null && message!.isNotEmpty;
-
- return Container(
- height: 44,
- padding: const EdgeInsets.symmetric(horizontal: 20),
- decoration: const BoxDecoration(
- color: BrainPalette.surfaceRaised,
- border: Border(bottom: BorderSide(color: BrainPalette.line)),
- ),
- child: Row(
- children: [
- const Text('DigitalBrain', style: BrainType.title),
- const SizedBox(width: 12),
- Text('chat:$chatName', style: BrainType.meta),
- const Spacer(),
- Container(
- width: 6,
- height: 6,
- decoration: BoxDecoration(
- shape: BoxShape.circle,
- color: offline ? BrainPalette.signal : BrainPalette.owner,
- ),
- ),
- const SizedBox(width: 8),
- Text(offline ? 'not connected' : 'connected', style: BrainType.meta),
- ],
- ),
- );
- }
-}
-
final class _EmptyJournal extends StatelessWidget {
const _EmptyJournal();
@@ -231,8 +500,22 @@ final class _EmptyJournal extends StatelessWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
+ Container(
+ width: 54,
+ height: 54,
+ decoration: BoxDecoration(
+ color: BrainPalette.surfaceRaised,
+ borderRadius: BorderRadius.circular(18),
+ border: Border.all(color: BrainPalette.line),
+ ),
+ child: const Icon(
+ Icons.chat_bubble_outline_rounded,
+ color: BrainPalette.textMuted,
+ ),
+ ),
+ const SizedBox(height: 18),
const Text('Nothing yet.', style: BrainType.empty),
- const SizedBox(height: 8),
+ const SizedBox(height: 7),
Text(
'Ask your brain to do something.',
style: BrainType.body.copyWith(color: BrainPalette.textMuted),
@@ -243,8 +526,6 @@ final class _EmptyJournal extends StatelessWidget {
}
}
-/// The signature: every turn keeps its real journal sequence in a monospace
-/// rail. Order here is durable kernel truth, not scroll position.
final class _Journal extends StatelessWidget {
const _Journal({
required this.turns,
@@ -261,11 +542,11 @@ final class _Journal extends StatelessWidget {
return Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
- constraints: const BoxConstraints(maxWidth: 760),
+ constraints: const BoxConstraints(maxWidth: 820),
child: ListView.builder(
key: const Key('chat_journal'),
controller: controller,
- padding: const EdgeInsets.symmetric(vertical: 28, horizontal: 20),
+ padding: const EdgeInsets.symmetric(vertical: 34, horizontal: 28),
itemCount: turns.length + (awaitingBrain ? 1 : 0),
itemBuilder: (context, index) {
if (index == turns.length) {
@@ -290,12 +571,12 @@ final class _JournalTurn extends StatelessWidget {
return Padding(
key: Key('turn_${turn.sequence}'),
- padding: const EdgeInsets.only(bottom: 26),
+ padding: const EdgeInsets.only(bottom: 28),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
- width: 44,
+ width: 48,
child: Padding(
padding: const EdgeInsets.only(top: 3),
child: Text(
@@ -304,17 +585,24 @@ final class _JournalTurn extends StatelessWidget {
),
),
),
- Container(width: 2, height: 20, color: voice),
- const SizedBox(width: 16),
+ Container(
+ width: 3,
+ height: 22,
+ decoration: BoxDecoration(
+ color: voice,
+ borderRadius: BorderRadius.circular(2),
+ ),
+ ),
+ const SizedBox(width: 18),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
turn.fromUser ? 'you' : 'brain',
- style: BrainType.meta.copyWith(color: voice),
+ style: BrainType.metaStrong.copyWith(color: voice),
),
- const SizedBox(height: 6),
+ const SizedBox(height: 7),
SelectableText(turn.text, style: BrainType.body),
],
),
@@ -331,16 +619,23 @@ final class _AwaitingTurn extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Padding(
- padding: const EdgeInsets.only(bottom: 26),
+ padding: const EdgeInsets.only(bottom: 28),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
- const SizedBox(width: 44),
- Container(width: 2, height: 20, color: BrainPalette.signal),
- const SizedBox(width: 16),
+ const SizedBox(width: 48),
+ Container(
+ width: 3,
+ height: 22,
+ decoration: BoxDecoration(
+ color: BrainPalette.signal,
+ borderRadius: BorderRadius.circular(2),
+ ),
+ ),
+ const SizedBox(width: 18),
Text(
'thinking',
- style: BrainType.meta.copyWith(color: BrainPalette.signal),
+ style: BrainType.metaStrong.copyWith(color: BrainPalette.signal),
),
],
),
@@ -357,8 +652,8 @@ final class _FailureNotice extends StatelessWidget {
Widget build(BuildContext context) {
return Container(
width: double.infinity,
- padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
- color: BrainPalette.surfaceRaised,
+ padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 11),
+ color: BrainPalette.signal.withValues(alpha: 0.08),
child: Text(
message,
style: BrainType.meta.copyWith(color: BrainPalette.signal),
@@ -387,61 +682,72 @@ final class _Composer extends StatelessWidget {
color: BrainPalette.surfaceRaised,
border: Border(top: BorderSide(color: BrainPalette.line)),
),
- padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
+ padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 16),
child: Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
- constraints: const BoxConstraints(maxWidth: 760),
- child: Row(
- crossAxisAlignment: CrossAxisAlignment.end,
- children: [
- Expanded(
- child: Shortcuts(
- shortcuts: const {
- SingleActivator(LogicalKeyboardKey.enter): _SubmitIntent(),
- },
- child: Actions(
- actions: {
- _SubmitIntent: CallbackAction<_SubmitIntent>(
- onInvoke: (_) {
- unawaited(onSubmit());
- return null;
- },
- ),
+ constraints: const BoxConstraints(maxWidth: 820),
+ child: Container(
+ decoration: BoxDecoration(
+ color: BrainPalette.surfaceSunken,
+ borderRadius: BorderRadius.circular(14),
+ border: Border.all(color: BrainPalette.lineStrong),
+ ),
+ padding: const EdgeInsets.only(left: 16, right: 8),
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.end,
+ children: [
+ Expanded(
+ child: Shortcuts(
+ shortcuts: const {
+ SingleActivator(LogicalKeyboardKey.enter):
+ _SubmitIntent(),
},
- child: TextField(
- key: const Key('chat_composer'),
- controller: controller,
- focusNode: focusNode,
- enabled: enabled,
- autofocus: true,
- maxLines: 4,
- minLines: 1,
- style: BrainType.body,
- cursorColor: BrainPalette.signal,
- decoration: InputDecoration(
- isDense: true,
- border: InputBorder.none,
- hintText: 'Ask your brain…',
- hintStyle: BrainType.body
- .copyWith(color: BrainPalette.textMuted),
+ child: Actions(
+ actions: {
+ _SubmitIntent: CallbackAction<_SubmitIntent>(
+ onInvoke: (_) {
+ unawaited(onSubmit());
+ return null;
+ },
+ ),
+ },
+ child: TextField(
+ key: const Key('chat_composer'),
+ controller: controller,
+ focusNode: focusNode,
+ enabled: enabled,
+ autofocus: true,
+ maxLines: 4,
+ minLines: 1,
+ style: BrainType.body,
+ cursorColor: BrainPalette.signal,
+ decoration: InputDecoration(
+ isDense: true,
+ border: InputBorder.none,
+ contentPadding: const EdgeInsets.symmetric(
+ vertical: 13,
+ ),
+ hintText: 'Ask your brain…',
+ hintStyle: BrainType.body.copyWith(
+ color: BrainPalette.textMuted,
+ ),
+ ),
),
),
),
),
- ),
- const SizedBox(width: 12),
- TextButton(
- key: const Key('chat_send'),
- onPressed: enabled ? () => unawaited(onSubmit()) : null,
- style: TextButton.styleFrom(
- foregroundColor: BrainPalette.signal,
- padding:
- const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
+ const SizedBox(width: 8),
+ IconButton(
+ key: const Key('chat_send'),
+ onPressed: enabled ? () => unawaited(onSubmit()) : null,
+ tooltip: 'Send',
+ color: BrainPalette.signal,
+ disabledColor: BrainPalette.textFaint,
+ icon: const Icon(Icons.arrow_upward_rounded),
),
- child: const Text('Send', style: BrainType.meta),
- ),
- ],
+ ],
+ ),
),
),
),
diff --git a/clients/digitalbrain_flutter/shell/lib/main.dart b/clients/digitalbrain_flutter/shell/lib/main.dart
index c95e2f64d..c6f167808 100644
--- a/clients/digitalbrain_flutter/shell/lib/main.dart
+++ b/clients/digitalbrain_flutter/shell/lib/main.dart
@@ -23,9 +23,10 @@ void main() {
chatName: chat,
statusMessage: status,
turns: edge?.watchChatTurns(chatName: chat),
+ topology: edge?.watchBrainTopology(),
onSend: edge == null
? null
: (text) => edge.sendMessage(chatName: chat, text: text),
),
);
-}
\ No newline at end of file
+}
diff --git a/clients/digitalbrain_flutter/shell/test/chat_screen_test.dart b/clients/digitalbrain_flutter/shell/test/chat_screen_test.dart
index 7112ec2c5..a3118fff2 100644
--- a/clients/digitalbrain_flutter/shell/test/chat_screen_test.dart
+++ b/clients/digitalbrain_flutter/shell/test/chat_screen_test.dart
@@ -5,14 +5,334 @@ import 'package:digitalbrain_flutter_shell/chat_screen.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
-ChatTurnEvent _turn(int sequence, bool fromUser, String text) => ChatTurnEvent(
- sequence: sequence,
- fromUser: fromUser,
- text: text,
- commandId: 'c$sequence',
- );
+ChatTurnEvent _turn(
+ int sequence,
+ bool fromUser,
+ String text, {
+ String? synapse,
+}) => ChatTurnEvent(
+ sequence: sequence,
+ fromUser: fromUser,
+ text: text,
+ commandId: 'c$sequence',
+ synapse: synapse ?? (fromUser ? 'UserMessaged' : 'AssistantResponded'),
+ neuronId: 'chat:owner/main',
+ caller: 'chat:owner/main',
+ correlationId: 'correlation-$sequence',
+ timestamp: DateTime.utc(2026, 7, 28, 8, 0, sequence),
+);
+
+BrainTopologySnapshot _topology() => BrainTopologySnapshot(
+ modules: const [
+ BrainModule(id: 'DigitalBrain.Chat.ChatModule'),
+ BrainModule(id: 'DigitalBrain.AI.AIModule'),
+ BrainModule(id: 'DigitalBrain.Flutter.FlutterModule'),
+ BrainModule(id: 'DigitalBrain.Google.GoogleModule'),
+ BrainModule(id: 'DigitalBrain.OS.OSBehaviorsModule'),
+ BrainModule(id: 'DigitalBrain.Salesforce.SalesforceModule'),
+ ],
+ neurons: const [
+ BrainNeuron(
+ id: 'chat:owner/main',
+ grainType: 'chat',
+ identity: 'owner/main',
+ placement: 'cluster-1',
+ ),
+ ],
+ observedAt: DateTime.utc(2026, 7, 28, 8),
+);
+
+BrainTopologySnapshot _topologyWithoutNeuron() => BrainTopologySnapshot(
+ modules: _topology().modules,
+ neurons: const [],
+ observedAt: DateTime.utc(2026, 7, 28, 8),
+);
void main() {
+ testWidgets('the workspace exposes Chat, Activity, and Brain destinations', (
+ tester,
+ ) async {
+ final topology = StreamController();
+ addTearDown(topology.close);
+
+ await tester.pumpWidget(
+ BrainChatApp(chatName: 'main', topology: topology.stream),
+ );
+ topology.add(_topology());
+ await tester.pumpAndSettle();
+
+ expect(find.byKey(const Key('destination_chat')), findsOneWidget);
+ expect(find.byKey(const Key('destination_activity')), findsOneWidget);
+ expect(find.byKey(const Key('destination_brain')), findsOneWidget);
+
+ await tester.tap(find.byKey(const Key('destination_activity')));
+ await tester.pumpAndSettle();
+ expect(find.byKey(const Key('activity_screen')), findsOneWidget);
+ expect(find.text('No activity yet.'), findsOneWidget);
+
+ await tester.tap(find.byKey(const Key('destination_brain')));
+ await tester.pumpAndSettle();
+ expect(find.byKey(const Key('brain_screen')), findsOneWidget);
+ });
+
+ testWidgets('activity shows journal facts without message content', (
+ tester,
+ ) async {
+ final turns = StreamController();
+ addTearDown(turns.close);
+
+ await tester.pumpWidget(
+ BrainChatApp(chatName: 'main', turns: turns.stream, onSend: (_) async {}),
+ );
+
+ turns.add(_turn(1, true, 'private customer message'));
+ await tester.pumpAndSettle();
+ await tester.tap(find.byKey(const Key('destination_activity')));
+ await tester.pumpAndSettle();
+
+ expect(find.text('UserMessaged'), findsOneWidget);
+ expect(find.text('sequence 001'), findsOneWidget);
+ expect(find.text('command c1'), findsOneWidget);
+ expect(find.text('private customer message'), findsNothing);
+ });
+
+ testWidgets('activity renders the authoritative synapse name', (
+ tester,
+ ) async {
+ final turns = StreamController();
+ addTearDown(turns.close);
+
+ await tester.pumpWidget(
+ BrainChatApp(chatName: 'main', turns: turns.stream, onSend: (_) async {}),
+ );
+ turns.add(
+ _turn(2, true, 'private payload', synapse: 'ObservedCustomSynapse'),
+ );
+ await tester.pumpAndSettle();
+ await tester.tap(find.byKey(const Key('destination_activity')));
+ await tester.pumpAndSettle();
+
+ expect(find.text('ObservedCustomSynapse'), findsOneWidget);
+ expect(find.text('UserMessaged'), findsNothing);
+ expect(find.text('private payload'), findsNothing);
+ });
+
+ testWidgets('the shared event projection survives destination changes', (
+ tester,
+ ) async {
+ final turns = StreamController();
+ addTearDown(turns.close);
+
+ await tester.pumpWidget(
+ BrainChatApp(chatName: 'main', turns: turns.stream, onSend: (_) async {}),
+ );
+
+ await tester.tap(find.byKey(const Key('destination_activity')));
+ await tester.pumpAndSettle();
+ turns.add(_turn(9, false, 'arrived while activity was open'));
+ await tester.pumpAndSettle();
+
+ expect(find.text('AssistantResponded'), findsOneWidget);
+
+ await tester.tap(find.byKey(const Key('destination_chat')));
+ await tester.pumpAndSettle();
+ expect(find.text('arrived while activity was open'), findsOneWidget);
+ expect(find.text('009'), findsOneWidget);
+ });
+
+ testWidgets('narrow windows use bottom navigation', (tester) async {
+ tester.view.physicalSize = const Size(600, 800);
+ tester.view.devicePixelRatio = 1;
+ addTearDown(tester.view.resetPhysicalSize);
+ addTearDown(tester.view.resetDevicePixelRatio);
+
+ await tester.pumpWidget(const BrainChatApp(chatName: 'main'));
+ await tester.pumpAndSettle();
+
+ expect(find.byType(NavigationBar), findsOneWidget);
+ expect(find.byType(NavigationRail), findsNothing);
+ });
+
+ testWidgets('Brain renders live modules and active neurons', (tester) async {
+ final topology = StreamController();
+ addTearDown(topology.close);
+
+ await tester.pumpWidget(
+ BrainChatApp(chatName: 'main', topology: topology.stream),
+ );
+ topology.add(_topology());
+ await tester.pumpAndSettle();
+ await tester.tap(find.byKey(const Key('destination_brain')));
+ await tester.pumpAndSettle();
+
+ expect(find.byKey(const Key('brain_topology_canvas')), findsOneWidget);
+ expect(find.byKey(const Key('topology_module_0')), findsOneWidget);
+ expect(find.byKey(const Key('topology_module_1')), findsOneWidget);
+ expect(find.text('Chat'), findsWidgets);
+ expect(find.text('AI'), findsOneWidget);
+ expect(find.text('chat:owner/main'), findsOneWidget);
+ });
+
+ testWidgets('Brain clears a transient topology failure after recovery', (
+ tester,
+ ) async {
+ final topology = StreamController();
+ addTearDown(topology.close);
+
+ await tester.pumpWidget(
+ BrainChatApp(chatName: 'main', topology: topology.stream),
+ );
+ topology.add(_topology());
+ await tester.pumpAndSettle();
+ await tester.tap(find.byKey(const Key('destination_brain')));
+ await tester.pumpAndSettle();
+
+ expect(find.text('Connected'), findsOneWidget);
+ expect(find.byKey(const Key('brain_topology_canvas')), findsOneWidget);
+
+ topology.addError(StateError('topology temporarily unavailable'));
+ await tester.pumpAndSettle();
+
+ expect(find.text('Offline'), findsOneWidget);
+ expect(find.byKey(const Key('brain_topology_canvas')), findsNothing);
+ expect(find.text('Waiting for live topology…'), findsOneWidget);
+
+ topology.add(_topology());
+ await tester.pumpAndSettle();
+
+ expect(find.text('Connected'), findsOneWidget);
+ expect(find.byKey(const Key('brain_topology_canvas')), findsOneWidget);
+ });
+
+ testWidgets('a chat turn pulses Brain and opens correlation inspector', (
+ tester,
+ ) async {
+ final topology = StreamController();
+ final turns = StreamController();
+ addTearDown(topology.close);
+ addTearDown(turns.close);
+
+ await tester.pumpWidget(
+ BrainChatApp(
+ chatName: 'main',
+ topology: topology.stream,
+ turns: turns.stream,
+ ),
+ );
+ topology.add(_topology());
+ turns.add(_turn(9, true, 'private pulse content'));
+ await tester.pumpAndSettle();
+ await tester.tap(find.byKey(const Key('destination_brain')));
+ await tester.pumpAndSettle();
+
+ expect(find.byKey(const Key('brain_pulse')), findsOneWidget);
+ expect(find.byKey(const Key('brain_local_pulse')), findsOneWidget);
+ expect(find.byKey(const Key('brain_edge_pulse')), findsNothing);
+ expect(find.byKey(const Key('brain_inspector')), findsOneWidget);
+ expect(find.text('correlation-9'), findsOneWidget);
+ expect(find.text('chat:owner/main'), findsWidgets);
+ expect(find.text('private pulse content'), findsNothing);
+ });
+
+ testWidgets('a pulse waits for its neuron to appear in live topology', (
+ tester,
+ ) async {
+ final topology = StreamController();
+ final turns = StreamController();
+ addTearDown(topology.close);
+ addTearDown(turns.close);
+
+ await tester.pumpWidget(
+ BrainChatApp(
+ chatName: 'main',
+ topology: topology.stream,
+ turns: turns.stream,
+ ),
+ );
+ topology.add(_topologyWithoutNeuron());
+ turns.add(_turn(12, false, 'not exposed by the topology view'));
+ await tester.pumpAndSettle();
+ await tester.tap(find.byKey(const Key('destination_brain')));
+ await tester.pumpAndSettle();
+
+ expect(find.byKey(const Key('brain_pulse')), findsNothing);
+
+ await tester.pump(const Duration(milliseconds: 1200));
+ topology.add(_topology());
+ await tester.pumpAndSettle();
+
+ expect(find.byKey(const Key('brain_pulse')), findsOneWidget);
+ });
+
+ testWidgets('Brain clears a neuron selection when the neuron disappears', (
+ tester,
+ ) async {
+ tester.view.physicalSize = const Size(1200, 1000);
+ tester.view.devicePixelRatio = 1;
+ addTearDown(tester.view.resetPhysicalSize);
+ addTearDown(tester.view.resetDevicePixelRatio);
+ final topology = StreamController();
+ addTearDown(topology.close);
+
+ await tester.pumpWidget(
+ BrainChatApp(chatName: 'main', topology: topology.stream),
+ );
+ topology.add(_topology());
+ await tester.pumpAndSettle();
+ await tester.tap(find.byKey(const Key('destination_brain')));
+ await tester.pumpAndSettle();
+ await tester.tap(find.byKey(const Key('topology_neuron_0')));
+ await tester.pumpAndSettle();
+
+ expect(find.text('cluster-1'), findsOneWidget);
+
+ topology.add(_topologyWithoutNeuron());
+ await tester.pumpAndSettle();
+
+ expect(find.text('cluster-1'), findsNothing);
+ expect(
+ find.text(
+ 'Select a module or neuron. New chat turns open their causal pulse automatically.',
+ ),
+ findsOneWidget,
+ );
+ });
+
+ testWidgets('Brain clears causal selection when the chat changes', (
+ tester,
+ ) async {
+ final topology = StreamController.broadcast();
+ final turns = StreamController.broadcast();
+ addTearDown(topology.close);
+ addTearDown(turns.close);
+
+ await tester.pumpWidget(
+ BrainChatApp(
+ chatName: 'main',
+ topology: topology.stream,
+ turns: turns.stream,
+ ),
+ );
+ topology.add(_topology());
+ turns.add(_turn(14, false, 'private turn'));
+ await tester.pumpAndSettle();
+ await tester.tap(find.byKey(const Key('destination_brain')));
+ await tester.pumpAndSettle();
+
+ expect(find.text('correlation-14'), findsOneWidget);
+
+ await tester.pumpWidget(
+ BrainChatApp(
+ chatName: 'other',
+ topology: topology.stream,
+ turns: turns.stream,
+ ),
+ );
+ await tester.pumpAndSettle();
+
+ expect(find.text('correlation-14'), findsNothing);
+ });
+
testWidgets('an empty conversation invites the owner to act', (tester) async {
await tester.pumpWidget(const BrainChatApp(chatName: 'main'));
await tester.pump();
@@ -22,8 +342,9 @@ void main() {
expect(find.byKey(const Key('chat_journal')), findsNothing);
});
- testWidgets('each turn carries its journal sequence and speaker',
- (tester) async {
+ testWidgets('each turn carries its journal sequence and speaker', (
+ tester,
+ ) async {
final turns = StreamController();
addTearDown(turns.close);
@@ -60,8 +381,9 @@ void main() {
expect(find.text('only once'), findsOneWidget);
});
- testWidgets('sending hands the text to the edge and awaits the brain',
- (tester) async {
+ testWidgets('sending hands the text to the edge and awaits the brain', (
+ tester,
+ ) async {
final sent = [];
final turns = StreamController();
addTearDown(turns.close);
@@ -91,15 +413,16 @@ void main() {
expect(find.text('Done.'), findsOneWidget);
});
- testWidgets('a disconnected edge disables sending and says so',
- (tester) async {
+ testWidgets('a disconnected edge disables sending and says so', (
+ tester,
+ ) async {
await tester.pumpWidget(
const BrainChatApp(chatName: 'main', statusMessage: 'no edge'),
);
await tester.pump();
expect(find.text('not connected'), findsOneWidget);
- final send = tester.widget(find.byKey(const Key('chat_send')));
+ final send = tester.widget(find.byKey(const Key('chat_send')));
expect(send.onPressed, isNull);
});
}
diff --git a/clients/digitalbrain_flutter/test/edge_client_test.dart b/clients/digitalbrain_flutter/test/edge_client_test.dart
index e7c9f0310..72663b529 100644
--- a/clients/digitalbrain_flutter/test/edge_client_test.dart
+++ b/clients/digitalbrain_flutter/test/edge_client_test.dart
@@ -16,11 +16,7 @@ void main() {
}),
);
- await client.openScene(
- shellName: 'desk',
- sceneKey: 'home',
- title: 'Home',
- );
+ await client.openScene(shellName: 'desk', sceneKey: 'home', title: 'Home');
expect(seen, isNotNull);
expect(seen!.method, 'POST');
@@ -153,4 +149,140 @@ data: {"sequence":3,"sceneKey":"home","title":"Home refreshed","commandId":"c","
throwsA(isA()),
);
});
+
+ test(
+ 'watchBrainTopology polls live modules and neurons without restart',
+ () async {
+ var requestCount = 0;
+ final client = DigitalBrainUiEdgeClient(
+ baseUri: Uri.parse('http://ui.example:5080'),
+ httpClient: MockClient((request) async {
+ expect(request.method, 'GET');
+ expect(
+ request.url.toString(),
+ 'http://ui.example:5080/brain/topology',
+ );
+ requestCount++;
+ return http.Response(
+ jsonEncode({
+ 'modules': [
+ {'id': 'DigitalBrain.Chat.ChatModule'},
+ ],
+ 'neurons': [
+ if (requestCount > 1)
+ {
+ 'id': 'chat:owner/main',
+ 'grainType': 'chat',
+ 'identity': 'owner/main',
+ 'placement': 'cluster-1',
+ },
+ ],
+ 'observedAt': '2026-07-28T08:00:00Z',
+ }),
+ 200,
+ headers: {'content-type': 'application/json'},
+ );
+ }),
+ );
+
+ final snapshots = await client
+ .watchBrainTopology(pollInterval: Duration.zero)
+ .take(2)
+ .toList();
+
+ expect(snapshots.first.neurons, isEmpty);
+ expect(snapshots.last.neurons.single.id, 'chat:owner/main');
+ },
+ );
+
+ test(
+ 'watchBrainTopology reports a transient failure and keeps polling',
+ () async {
+ var requestCount = 0;
+ final client = DigitalBrainUiEdgeClient(
+ baseUri: Uri.parse('http://ui.example:5080'),
+ httpClient: MockClient((request) async {
+ requestCount++;
+ if (requestCount == 1) {
+ return http.Response('temporarily unavailable', 503);
+ }
+ return http.Response(
+ jsonEncode({
+ 'modules': [
+ {'id': 'DigitalBrain.Chat.ChatModule'},
+ ],
+ 'neurons': const [],
+ 'observedAt': '2026-07-28T08:00:00Z',
+ }),
+ 200,
+ headers: {'content-type': 'application/json'},
+ );
+ }),
+ );
+ final errors =