feat(client): 모듈형 OTO 콘솔 패키지를 추가한다

iop와 nomadcode가 같은 workspace에서 제품별 Flutter 콘솔을 위젯으로 삽입할 수 있어야 하므로 OTO client를 얇은 host 앱으로 정리하고 agent-shell 기반 embeddable console 패키지를 분리한다.
This commit is contained in:
toki 2026-06-06 18:14:06 +09:00
parent 1201f59e2a
commit fac57a96fd
15 changed files with 1002 additions and 86 deletions

View file

@ -1,17 +1,30 @@
# oto_client # OTO Client
A new Flutter project. Flutter host application for the OTO console.
## Getting Started The embeddable UI surface lives in `packages/flutter/oto_console` so other
workspace apps, such as `../nomadcode/apps/client`, can mount OTO as a modular
Flutter widget instead of depending on this host application's `main.dart`.
This project is a starting point for a Flutter application. ## Structure
A few resources to get you started if this is your first Flutter project: - `lib/main.dart` only starts the OTO client host.
- `lib/src/app/bootstrap.dart` owns app startup concerns such as fullscreen
system UI setup.
- `lib/src/app/oto_client_app.dart` wires the host `MaterialApp` to
`OtoConsoleShell`.
- `../../packages/flutter/oto_console` exports the product-owned embeddable
widgets. That package consumes the workspace shared `agent-shell` package.
- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter) ## Embedding
- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
For help getting started with Flutter development, view the Add the package from another workspace Flutter app:
[online documentation](https://docs.flutter.dev/), which offers tutorials,
samples, guidance on mobile development, and a full API reference. ```yaml
dependencies:
oto_console:
path: ../../../oto/packages/flutter/oto_console
```
Then mount any exported widget, for example `OtoConsoleShell` or
`OtoAgentPanel`, inside the host application's own workbench shell.

View file

@ -1,56 +1,3 @@
import 'package:flutter/material.dart'; import 'src/app/bootstrap.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'OTO Console',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blueGrey),
useMaterial3: true,
),
home: const MyHomePage(title: 'OTO Console'),
);
}
}
class MyHomePage extends StatelessWidget {
const MyHomePage({super.key, required this.title});
final String title;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: Text(title),
),
body: const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.dashboard, size: 64, color: Colors.blueGrey),
SizedBox(height: 16),
Text(
'Welcome to OTO Console',
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
SizedBox(height: 8),
Text(
'Independent Control Plane',
style: TextStyle(fontSize: 16, color: Colors.grey),
),
],
),
),
);
}
}
Future<void> main() => runOtoClient();

View file

@ -0,0 +1,25 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'oto_client_app.dart';
Future<void> applyFullscreenMode() async {
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
systemNavigationBarColor: Colors.transparent,
systemNavigationBarDividerColor: Colors.transparent,
),
);
}
Future<void> bootstrapOtoClient() async {
await applyFullscreenMode();
}
Future<void> runOtoClient() async {
WidgetsFlutterBinding.ensureInitialized();
await bootstrapOtoClient();
runApp(const OtoClientApp());
}

View file

@ -0,0 +1,77 @@
import 'package:flutter/material.dart';
import 'package:oto_console/oto_console.dart';
const otoDefaultCapabilityPack = OtoCapabilityPack(
capabilities: [
OtoCapability(
name: 'Runner Registry',
description: 'Track connected build runners and bootstrap status.',
),
OtoCapability(
name: 'Remote Run',
description: 'Dispatch typed command build and deploy pipelines.',
),
OtoCapability(
name: 'Execution Status',
description: 'Follow active runs, cancellation, and terminal state.',
),
OtoCapability(
name: 'Step Logs',
description: 'Inspect live command output and execution traces.',
),
OtoCapability(
name: 'Artifacts',
description: 'Review build output events and published artifacts.',
),
],
);
class OtoClientApp extends StatelessWidget {
final OtoConsoleConfig config;
final String statusText;
final OtoCapabilityPack capabilities;
const OtoClientApp({
super.key,
this.config = const OtoConsoleConfig(
serverHttpUrl: String.fromEnvironment(
'OTO_SERVER_HTTP_URL',
defaultValue: 'http://localhost:8080',
),
serverWireUrl: String.fromEnvironment(
'OTO_SERVER_WIRE_URL',
defaultValue: 'ws://localhost:18080/runner',
),
),
this.statusText = 'Disconnected',
this.capabilities = otoDefaultCapabilityPack,
});
@override
Widget build(BuildContext context) {
const themeAdapter = OtoConsoleThemeAdapter();
return MaterialApp(
title: 'OTO Client',
debugShowCheckedModeBanner: false,
theme: ThemeData.dark(useMaterial3: true).copyWith(
colorScheme: ColorScheme.fromSeed(
seedColor: themeAdapter.primaryColor,
brightness: Brightness.dark,
primary: themeAdapter.primaryColor,
secondary: themeAdapter.accentColor,
),
scaffoldBackgroundColor: themeAdapter.backgroundColor,
),
home: OtoConsoleShell(
config: config,
capabilities: capabilities,
themeAdapter: themeAdapter,
overview: OtoConsoleOverview(
config: config,
statusText: statusText,
themeAdapter: themeAdapter,
),
),
);
}
}

View file

@ -35,6 +35,14 @@ dependencies:
# Use with the CupertinoIcons class for iOS style icons. # Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8 cupertino_icons: ^1.0.8
# Shared embeddable agent interaction shell.
agent_shell:
path: ../../../agent-shell
# OTO-owned embeddable console widgets.
oto_console:
path: ../../packages/flutter/oto_console
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter

View file

@ -1,29 +1,18 @@
// This is a basic Flutter widget test for OTO Console app shell.
//
// To perform an interaction with a widget in your test, use the WidgetTester
// utility in the flutter_test package. For example, you can send tap and scroll
// gestures. You can also use WidgetTester to find child widgets in the widget
// tree, read text, and verify that the values of widget properties are correct.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:oto_client/main.dart'; import 'package:oto_client/src/app/oto_client_app.dart';
void main() { void main() {
testWidgets('OTO Console app shell rendering test', (WidgetTester tester) async { testWidgets('OTO client hosts embeddable console widgets', (tester) async {
// Build our app and trigger a frame. await tester.pumpWidget(const OtoClientApp());
await tester.pumpWidget(const MyApp());
// Verify that our app bar shows the correct title. expect(find.text('Build Operations Overview'), findsOneWidget);
expect(find.text('OTO Console'), findsOneWidget); expect(find.byTooltip('Agent'), findsOneWidget);
// Verify that welcome text and subtext are rendered. await tester.tap(find.byTooltip('Agent'));
expect(find.text('Welcome to OTO Console'), findsOneWidget); await tester.pumpAndSettle();
expect(find.text('Independent Control Plane'), findsOneWidget);
// Verify dashboard icon is present. expect(find.textContaining('OTO agent surface is ready'), findsOneWidget);
expect(find.byIcon(Icons.dashboard), findsOneWidget); expect(find.textContaining('Runner Registry'), findsOneWidget);
}); });
} }

12
packages/flutter/oto_console/.gitignore vendored Normal file
View file

@ -0,0 +1,12 @@
# Flutter/Dart generated files
.dart_tool/
.flutter-plugins
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
/coverage/
# Local IDE files
.idea/
*.iml

View file

@ -0,0 +1,6 @@
include: package:flutter_lints/flutter.yaml
linter:
rules:
prefer_const_constructors: true
prefer_final_fields: true

View file

@ -0,0 +1,4 @@
export 'src/oto_agent_panel.dart';
export 'src/oto_console_contract.dart';
export 'src/oto_console_overview.dart';
export 'src/oto_console_shell.dart';

View file

@ -0,0 +1,42 @@
import 'package:agent_shell/agent_shell.dart';
import 'package:flutter/material.dart';
import 'oto_console_contract.dart';
class OtoAgentPanel extends StatelessWidget {
final ValueChanged<String>? onSubmit;
final OtoCapabilityPack? capabilities;
const OtoAgentPanel({super.key, this.onSubmit, this.capabilities});
List<AgentMessage> _buildMessages() {
final introText = StringBuffer(
'OTO agent surface is ready. Runner, pipeline, execution, log, and artifact capabilities can be registered here.',
);
if (capabilities != null && capabilities!.capabilities.isNotEmpty) {
introText.write('\n\nRegistered capabilities:');
for (final capability in capabilities!.capabilities) {
introText.write('\n- ${capability.name}: ${capability.description}');
}
}
return [
AgentMessage(
id: 'oto-agent-intro',
role: AgentMessageRole.assistant,
text: introText.toString(),
),
];
}
@override
Widget build(BuildContext context) {
return AgentShell(
messages: _buildMessages(),
busy: false,
placeholder: 'Ask about OTO builds',
onSubmit: onSubmit,
);
}
}

View file

@ -0,0 +1,61 @@
import 'package:flutter/material.dart';
import 'oto_console_shell.dart';
class OtoConsoleConfig {
final String serverHttpUrl;
final String serverWireUrl;
final String? authTokenReference;
const OtoConsoleConfig({
required this.serverHttpUrl,
required this.serverWireUrl,
this.authTokenReference,
});
}
class OtoConsoleThemeAdapter {
final Color primaryColor;
final Color accentColor;
final Color backgroundColor;
final Color railBackgroundColor;
final Color borderColor;
final Color textColor;
final Color textSecondaryColor;
const OtoConsoleThemeAdapter({
this.primaryColor = const Color(0xFF22D3EE),
this.accentColor = const Color(0xFFF59E0B),
this.backgroundColor = const Color(0xFF111318),
this.railBackgroundColor = const Color(0xFF181B22),
this.borderColor = const Color(0xFF2A2F3A),
this.textColor = Colors.white,
this.textSecondaryColor = Colors.white60,
});
}
typedef OtoConsoleNavigationCallback = void Function(OtoConsoleSection section);
class OtoConsoleNavigation {
final OtoConsoleNavigationCallback? onNavigate;
const OtoConsoleNavigation({this.onNavigate});
}
class OtoCapability {
final String name;
final String description;
final IconData? icon;
const OtoCapability({
required this.name,
required this.description,
this.icon,
});
}
class OtoCapabilityPack {
final List<OtoCapability> capabilities;
const OtoCapabilityPack({required this.capabilities});
}

View file

@ -0,0 +1,362 @@
import 'package:flutter/material.dart';
import 'oto_console_contract.dart';
class OtoConsoleOverview extends StatelessWidget {
final OtoConsoleConfig config;
final String statusText;
final VoidCallback? onRefresh;
final OtoConsoleThemeAdapter? themeAdapter;
const OtoConsoleOverview({
super.key,
required this.config,
required this.statusText,
this.onRefresh,
this.themeAdapter,
});
bool get _connected => statusText.trim().toLowerCase() == 'connected';
@override
Widget build(BuildContext context) {
final theme = themeAdapter ?? const OtoConsoleThemeAdapter();
final statusColor = _connected ? theme.primaryColor : theme.accentColor;
return Container(
color: theme.backgroundColor,
padding: const EdgeInsets.all(24),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 840),
child: ListView(
children: [
LayoutBuilder(
builder: (context, constraints) {
final narrow = constraints.maxWidth < 620;
final title = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'OTO CONTROL PLANE',
style: TextStyle(
color: theme.primaryColor,
fontSize: 12,
fontWeight: FontWeight.bold,
letterSpacing: 1.8,
),
),
const SizedBox(height: 4),
Text(
'Build Operations Overview',
style: TextStyle(
color: theme.textColor,
fontSize: 28,
fontWeight: FontWeight.w800,
),
),
],
);
final badge = _StatusBadge(
label: statusText,
color: statusColor,
);
if (narrow) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [title, const SizedBox(height: 16), badge],
);
}
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(child: title),
const SizedBox(width: 24),
badge,
],
);
},
),
const SizedBox(height: 32),
_SectionHeader(
title: 'SERVER ENDPOINTS',
icon: Icons.lan_outlined,
theme: theme,
),
const SizedBox(height: 12),
_EndpointTile(
title: 'OTO Server HTTP API',
value: config.serverHttpUrl,
icon: Icons.http_outlined,
theme: theme,
),
const SizedBox(height: 16),
_EndpointTile(
title: 'OTO Server Wire',
value: config.serverWireUrl,
icon: Icons.swap_calls_outlined,
theme: theme,
),
const SizedBox(height: 32),
_SectionHeader(
title: 'RUNNER EXECUTION SURFACE',
icon: Icons.terminal_outlined,
theme: theme,
),
const SizedBox(height: 12),
_MetricGrid(theme: theme, connected: _connected),
const SizedBox(height: 32),
_SectionHeader(
title: 'AUTHENTICATION',
icon: Icons.security_outlined,
theme: theme,
),
const SizedBox(height: 12),
_EndpointTile(
title: 'Active Auth Token Reference',
value: config.authTokenReference?.isNotEmpty == true
? config.authTokenReference!
: 'None',
icon: Icons.key_outlined,
theme: theme,
),
if (onRefresh != null) ...[
const SizedBox(height: 32),
Align(
alignment: Alignment.centerLeft,
child: FilledButton.icon(
onPressed: onRefresh,
icon: const Icon(Icons.refresh),
label: const Text('Refresh'),
),
),
],
],
),
),
),
);
}
}
class _StatusBadge extends StatelessWidget {
final String label;
final Color color;
const _StatusBadge({required this.label, required this.color});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: color.withValues(alpha: 0.5)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
const SizedBox(width: 8),
Flexible(
child: Text(
label.toUpperCase(),
overflow: TextOverflow.ellipsis,
maxLines: 1,
style: TextStyle(
color: color,
fontSize: 12,
fontWeight: FontWeight.w800,
),
),
),
],
),
);
}
}
class _SectionHeader extends StatelessWidget {
final String title;
final IconData icon;
final OtoConsoleThemeAdapter theme;
const _SectionHeader({
required this.title,
required this.icon,
required this.theme,
});
@override
Widget build(BuildContext context) {
return Row(
children: [
Icon(icon, color: theme.primaryColor, size: 18),
const SizedBox(width: 8),
Expanded(
child: Text(
title,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: theme.textSecondaryColor,
fontSize: 12,
fontWeight: FontWeight.w800,
),
),
),
],
);
}
}
class _EndpointTile extends StatelessWidget {
final String title;
final String value;
final IconData icon;
final OtoConsoleThemeAdapter theme;
const _EndpointTile({
required this.title,
required this.value,
required this.icon,
required this.theme,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: theme.railBackgroundColor,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: theme.borderColor),
),
child: Row(
children: [
Icon(icon, color: theme.primaryColor, size: 22),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: theme.textSecondaryColor,
fontSize: 12,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Text(
value,
overflow: TextOverflow.ellipsis,
maxLines: 1,
style: TextStyle(
color: theme.textColor,
fontSize: 15,
fontWeight: FontWeight.w700,
),
),
],
),
),
],
),
);
}
}
class _MetricGrid extends StatelessWidget {
final OtoConsoleThemeAdapter theme;
final bool connected;
const _MetricGrid({required this.theme, required this.connected});
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final narrow = constraints.maxWidth < 620;
final tiles = [
_MetricTile(
title: 'Status',
value: connected ? 'ACTIVE WIRE' : 'OFFLINE',
color: connected ? theme.primaryColor : theme.textSecondaryColor,
theme: theme,
),
_MetricTile(
title: 'Domain',
value: 'Build / Deploy',
color: theme.accentColor,
theme: theme,
),
];
if (narrow) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [tiles[0], const SizedBox(height: 16), tiles[1]],
);
}
return Row(
children: [
Expanded(child: tiles[0]),
const SizedBox(width: 16),
Expanded(child: tiles[1]),
],
);
},
);
}
}
class _MetricTile extends StatelessWidget {
final String title;
final String value;
final Color color;
final OtoConsoleThemeAdapter theme;
const _MetricTile({
required this.title,
required this.value,
required this.color,
required this.theme,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: color.withValues(alpha: 0.25)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(color: theme.textSecondaryColor, fontSize: 12),
),
const SizedBox(height: 6),
Text(
value,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: color,
fontSize: 18,
fontWeight: FontWeight.w800,
),
),
],
),
);
}
}

View file

@ -0,0 +1,307 @@
import 'package:flutter/material.dart';
import 'oto_agent_panel.dart';
import 'oto_console_contract.dart';
enum OtoConsoleSection {
overview,
runners,
pipelines,
executions,
artifacts,
agent,
settings,
}
class OtoConsoleShell extends StatefulWidget {
final Widget overview;
final Widget? runners;
final Widget? pipelines;
final Widget? executions;
final Widget? artifacts;
final Widget? agent;
final Widget? settings;
final OtoConsoleSection initialSection;
final OtoConsoleConfig? config;
final OtoConsoleThemeAdapter? themeAdapter;
final OtoConsoleNavigationCallback? onNavigate;
final OtoCapabilityPack? capabilities;
const OtoConsoleShell({
super.key,
required this.overview,
this.runners,
this.pipelines,
this.executions,
this.artifacts,
this.agent,
this.settings,
this.initialSection = OtoConsoleSection.overview,
this.config,
this.themeAdapter,
this.onNavigate,
this.capabilities,
});
@override
State<OtoConsoleShell> createState() => _OtoConsoleShellState();
}
class _OtoConsoleShellState extends State<OtoConsoleShell> {
late OtoConsoleSection _section = widget.initialSection;
@override
Widget build(BuildContext context) {
final theme = widget.themeAdapter ?? const OtoConsoleThemeAdapter();
return Scaffold(
backgroundColor: theme.backgroundColor,
body: SafeArea(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_OtoConsoleRail(
selected: _section,
onSelect: _select,
theme: theme,
),
Expanded(child: _buildContent(theme)),
],
),
),
);
}
void _select(OtoConsoleSection section) {
setState(() {
_section = section;
});
widget.onNavigate?.call(section);
}
Widget _buildContent(OtoConsoleThemeAdapter theme) {
return switch (_section) {
OtoConsoleSection.overview => widget.overview,
OtoConsoleSection.runners =>
widget.runners ??
_OtoPlaceholder(
icon: Icons.precision_manufacturing_outlined,
title: 'Runners',
subtitle:
'Runner registry, heartbeat, and bootstrap controls mount here.',
theme: theme,
),
OtoConsoleSection.pipelines =>
widget.pipelines ??
_OtoPlaceholder(
icon: Icons.account_tree_outlined,
title: 'Pipelines',
subtitle:
'Typed command pipelines and build profiles mount here.',
theme: theme,
),
OtoConsoleSection.executions =>
widget.executions ??
_OtoPlaceholder(
icon: Icons.play_circle_outline,
title: 'Executions',
subtitle:
'Remote run status, cancellation, and execution history mount here.',
theme: theme,
),
OtoConsoleSection.artifacts =>
widget.artifacts ??
_OtoPlaceholder(
icon: Icons.inventory_2_outlined,
title: 'Artifacts',
subtitle:
'Build outputs, artifact events, and retention views mount here.',
theme: theme,
),
OtoConsoleSection.agent =>
widget.agent ?? OtoAgentPanel(capabilities: widget.capabilities),
OtoConsoleSection.settings =>
widget.settings ??
_OtoPlaceholder(
icon: Icons.tune,
title: 'Settings',
subtitle:
'Server endpoints, credentials, and operator preferences mount here.',
theme: theme,
),
};
}
}
class _OtoConsoleRail extends StatelessWidget {
final OtoConsoleSection selected;
final ValueChanged<OtoConsoleSection> onSelect;
final OtoConsoleThemeAdapter theme;
const _OtoConsoleRail({
required this.selected,
required this.onSelect,
required this.theme,
});
@override
Widget build(BuildContext context) {
return Container(
width: 60,
decoration: BoxDecoration(
color: theme.railBackgroundColor,
border: Border(right: BorderSide(color: theme.borderColor)),
),
child: Column(
children: [
const SizedBox(height: 8),
_RailButton(
tooltip: 'Overview',
icon: Icons.dashboard_outlined,
selected: selected == OtoConsoleSection.overview,
onPressed: () => onSelect(OtoConsoleSection.overview),
theme: theme,
),
_RailButton(
tooltip: 'Runners',
icon: Icons.precision_manufacturing_outlined,
selected: selected == OtoConsoleSection.runners,
onPressed: () => onSelect(OtoConsoleSection.runners),
theme: theme,
),
_RailButton(
tooltip: 'Pipelines',
icon: Icons.account_tree_outlined,
selected: selected == OtoConsoleSection.pipelines,
onPressed: () => onSelect(OtoConsoleSection.pipelines),
theme: theme,
),
_RailButton(
tooltip: 'Executions',
icon: Icons.play_circle_outline,
selected: selected == OtoConsoleSection.executions,
onPressed: () => onSelect(OtoConsoleSection.executions),
theme: theme,
),
_RailButton(
tooltip: 'Artifacts',
icon: Icons.inventory_2_outlined,
selected: selected == OtoConsoleSection.artifacts,
onPressed: () => onSelect(OtoConsoleSection.artifacts),
theme: theme,
),
const Spacer(),
_RailButton(
tooltip: 'Agent',
icon: Icons.smart_toy_outlined,
selected: selected == OtoConsoleSection.agent,
onPressed: () => onSelect(OtoConsoleSection.agent),
theme: theme,
),
_RailButton(
tooltip: 'Settings',
icon: Icons.tune,
selected: selected == OtoConsoleSection.settings,
onPressed: () => onSelect(OtoConsoleSection.settings),
theme: theme,
),
const SizedBox(height: 8),
],
),
);
}
}
class _RailButton extends StatelessWidget {
final String tooltip;
final IconData icon;
final bool selected;
final VoidCallback onPressed;
final OtoConsoleThemeAdapter theme;
const _RailButton({
required this.tooltip,
required this.icon,
required this.selected,
required this.onPressed,
required this.theme,
});
@override
Widget build(BuildContext context) {
final color = selected ? theme.primaryColor : theme.textSecondaryColor;
return Tooltip(
message: tooltip,
waitDuration: const Duration(milliseconds: 500),
child: SizedBox(
width: 56,
height: 48,
child: IconButton(
onPressed: onPressed,
icon: Icon(icon, color: color),
style: IconButton.styleFrom(
backgroundColor: selected
? theme.primaryColor.withValues(alpha: 0.1)
: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
),
);
}
}
class _OtoPlaceholder extends StatelessWidget {
final IconData icon;
final String title;
final String subtitle;
final OtoConsoleThemeAdapter theme;
const _OtoPlaceholder({
required this.icon,
required this.title,
required this.subtitle,
required this.theme,
});
@override
Widget build(BuildContext context) {
return Container(
color: theme.backgroundColor,
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 500),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: theme.primaryColor, size: 40),
const SizedBox(height: 16),
Text(
title,
textAlign: TextAlign.center,
style: TextStyle(
color: theme.textColor,
fontSize: 22,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 8),
Text(
subtitle,
textAlign: TextAlign.center,
style: TextStyle(
color: theme.textSecondaryColor,
fontSize: 14,
),
),
],
),
),
),
),
);
}
}

View file

@ -0,0 +1,22 @@
name: oto_console
description: "Embeddable Flutter console widgets for OTO build and deploy operations."
version: 0.1.0
publish_to: 'none'
environment:
sdk: ^3.11.3
dependencies:
flutter:
sdk: flutter
agent_shell:
path: ../../../../agent-shell
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^6.0.0
flutter:
uses-material-design: true

View file

@ -0,0 +1,41 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:oto_console/oto_console.dart';
void main() {
testWidgets('renders embeddable OTO console shell', (tester) async {
const config = OtoConsoleConfig(
serverHttpUrl: 'http://localhost:8080',
serverWireUrl: 'ws://localhost:18080/runner',
);
await tester.pumpWidget(
const MaterialApp(
home: OtoConsoleShell(
config: config,
capabilities: OtoCapabilityPack(
capabilities: [
OtoCapability(
name: 'Remote Run',
description: 'Dispatch remote build runs.',
),
],
),
overview: OtoConsoleOverview(
config: config,
statusText: 'Disconnected',
),
),
),
);
expect(find.text('Build Operations Overview'), findsOneWidget);
expect(find.byTooltip('Agent'), findsOneWidget);
await tester.tap(find.byTooltip('Agent'));
await tester.pumpAndSettle();
expect(find.textContaining('OTO agent surface is ready'), findsOneWidget);
expect(find.textContaining('Remote Run'), findsOneWidget);
});
}