Software EngineeringDeveloper ToolsFlutter

Building a Review-Focused Flutter Client for Jules

How I built a Flutter client that keeps Jules sessions, approvals, and code diffs in one workspace, with account-scoped caching and visible sync state.

Light-mode Jules Flutter client with a session sidebar, activity timeline, and code diff panel
Lead image Light-mode Jules Flutter client with a session sidebar, activity timeline, and code diff panel
On this page

Audio edition

0:00 / 7:13

Ready

I built a Flutter client for Jules because I wanted the review work to stay beside the conversation that produced it.

A Jules session is more than a sequence of messages. It can include a plan waiting for approval, progress updates, repository context, and a patch to inspect. In a narrow chat view, those parts compete for the same space. On a desktop, they can remain visible together.

The first version of my client therefore uses three panes: session history, the activity timeline, and a code diff. It also caches sessions locally so reopening the app does not begin with an empty screen.

Light-mode Jules Flutter client with a session sidebar, activity timeline, and code diff panel
The desktop layout keeps session history, the agent timeline, and the latest code changes in one workspace.

What the first release needed to do

I gave the first release three jobs:

  • Keep accounts separate when switching between Jules API keys.
  • Restore useful session context from local storage, then refresh it from the API.
  • Let me read the activity timeline, approve a plan, send a follow-up, and inspect the latest diff without moving between unrelated screens.

This scope shaped the architecture. I did not need a complicated domain layer, but I did need one dependable boundary between Flutter widgets, Jules API calls, and the Hive cache.

Flutter UI
  -> Provider state
    -> JulesRepository
      -> ApiClient
      -> LocalStorageService

Widgets deal with selections, loading states, panes, and settings. The repository owns the operations that combine remote and local data:

class JulesRepository {
  final ApiClient _apiClient;
  final LocalStorageService _local;
  final String accountId;

  JulesRepository(this._apiClient, this._local, {required this.accountId});
}

That separation has been useful in practice. A button can ask the provider to approve a plan without also knowing the endpoint path or how the response affects cached state.

Scope cached data by account

Supporting multiple API keys creates a local data boundary. If I switch from a personal account to a work account, sessions from the first account must not remain visible in the second account’s workspace.

The storage layer handles this by prefixing Hive keys with the local account identifier:

String _scopedKey(String key, String? accountId) {
  if (accountId == null || accountId.isEmpty) return key;
  return '$accountId::$key';
}

Session records, activity records, and indexing metadata all use the same convention. When the active account changes, the provider also clears its in-memory selections and creates a new repository with the new account ID.

This is isolation inside the application, not a complete secret-storage design. The current client persists API keys locally in Hive. For a personal prototype that may be an acceptable trade-off, but a distributed desktop release should move credentials to OS-backed secure storage and define what account deletion does to that account’s cached sessions. Prefixing keys prevents accidental mixing; it does not encrypt them.

Show cached sessions before waiting for the network

The session provider loads local records first:

_sessions = await repo.getSessions();
_restoreCurrentSessionFromList(
  allowDefaultSelection: !_hasUserSelectedView,
);
notifyListeners();

It then starts the remote refresh and merges the returned sessions:

_isSyncing = true;
notifyListeners();

final response = await repo.syncSessions(pageSize: 100);
_sessions = _mergeSessions(_sessions, response.sessions);

The ordering is the important part. Previously seen sessions can appear without waiting for a request. The network then updates that snapshot and starts filling older paginated history in the background.

Cached data is necessarily stale. The interface therefore exposes syncing and error state rather than presenting the local snapshot as current. If the refresh fails but cached sessions exist, the history remains usable; it just cannot describe work that happened after the last successful sync.

Treat a session as typed activity

The API responses already distinguish several kinds of activity. I preserved that distinction in the client model:

class Activity {
  final ActivityOriginator originator;
  final String? description;
  final Plan? planGenerated;
  final String? userMessage;
  final String? agentMessage;
  final ProgressUpdate? progressUpdated;
  final List<Artifact> artifacts;
}

This lets the UI render a plan differently from a user prompt, and a change set differently from a progress message. More importantly, it makes the available action clear: a plan may need approval, while an agent message may need a follow-up.

Flattening all of those records into chat bubbles would discard information that the review workflow needs. A timeline can still read like a conversation while retaining the types supplied by the API.

Keep the latest patch beside the timeline

The diff panel looks for activities containing change-set artifacts and displays the most recent group:

final codeActivities = session?.activities
    .where((a) => a.artifacts.any((art) => art.changeSet != null))
    .toList() ?? [];

final changeSets = codeActivities.isEmpty
    ? []
    : codeActivities.last.artifacts
        .where((art) => art.changeSet != null)
        .map((art) => art.changeSet!)
        .toList();

Keeping that panel beside the timeline reduces the amount of context I have to reconstruct. I can compare the request, plan, status messages, and resulting files before deciding what to ask Jules to change next.

The current diff renderer is intentionally limited. It parses unified-diff hunks, tracks old and new line numbers, and styles additions and removals:

if (line.startsWith('@@')) {
  final match = RegExp(
    r'@@ -(\d+),?\d* \+(\d+),?\d* @@',
  ).firstMatch(line);
  if (match != null) {
    oldLine = int.parse(match.group(1)!);
    newLine = int.parse(match.group(2)!);
  }
} else if (line.startsWith('+')) {
  result.add(_ParsedLine(line.substring(1), _LineType.added, '+', null, newLine));
  newLine++;
} else if (line.startsWith('-')) {
  result.add(_ParsedLine(line.substring(1), _LineType.removed, '-', oldLine, null));
  oldLine++;
}

That is enough for ordinary text patches, but it is not yet a replacement for a mature review tool. Large patches need file navigation, and edge cases such as binary changes, renames, and unusually formed hunks need deliberate handling. The panel is useful because it puts review in the main workflow, not because this first parser is complete.

Adapt the panes without pretending every screen is a desktop

The wide layout is the version I designed around:

Sidebar | Activity timeline | Diff panel

Flutter still has to make the same workflow usable at smaller widths. The home screen derives two breakpoints from the available width:

final width = MediaQuery.of(context).size.width;
final isMobile = width < AppConstants.mobileBreakpoint;
final isTablet = width < AppConstants.tabletBreakpoint;

At mobile width, the sidebar moves into a drawer. At tablet width, the diff panel is removed from the main row so the timeline has room. At desktop width, the sidebar can remain open and the diff panel can be resized or hidden.

This is a compromise, not identical capability at every width. The three-pane review loop works best on a large screen. Smaller layouts preserve access to sessions and conversation, while detailed patch review remains more comfortable on desktop.

Make connection state visible, but do not overstate it

The client listens for connectivity changes and requests a refresh when the device reports that a connection has returned:

_connectivitySubscription =
    _connectivity?.onConnectivityChanged.listen((isOnline) {
  if (isOnline) {
    refreshSessions();
  }
});

That signal means a network interface is available; it does not prove that the Jules API is reachable. The request can still fail, so the provider also records sync errors and the UI shows an offline indicator for socket failures.

This distinction matters for an asynchronous tool. A cached session can explain the last known state, but only a successful refresh can show whether Jules has progressed elsewhere.

What I would improve next

The first release established the workspace I wanted, but several parts still need work:

  • Navigate large change sets by file and remember per-file review state.
  • Handle more unified-diff edge cases and richer artifact types.
  • Make pagination controls and background history indexing easier to understand.
  • Let the user choose a starting branch instead of always creating sessions from main.
  • Store API credentials with platform secure-storage facilities.
  • Package and test desktop releases beyond the development environment.

The source is available in the flutter_native_jules repository . It shows the exact boundary of the current implementation: a useful Flutter review client, with a cache and responsive workspace, that still has security and review-depth work ahead of it.

Dark-mode Jules Flutter client showing the same three-part review workspace
The dark theme keeps the same session, timeline, and diff structure.

Series

Jules Engineering

  1. 01 Building a Review-Focused Flutter Client for Jules Current note