Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion mobile/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ This file applies under `mobile/`. The root `AGENTS.md` also applies; this file
- Terminal output may arrive as a binary WebSocket message (`[u16be idLength][sessionId][raw bytes]`) instead of base64 inside JSON. The app asks for it with `binaryFrames: true` in `mobile.hello` and the runtime answers with what it granted; feature-detect through that response and `runtimeCapabilities`, never through `aleraMobileProtocolVersion`. The WebSocket already delimits messages, so there is no length-prefixed framing here, unlike the desktop socket.
- Requests the gateway accepts from mobile clients are allowlisted in `rust/alera-cli/src/terminal_host/server/mobile_gateway_surface.rs` (`mobile_request_allowed`). Adding a new request type to the mobile app requires allowlisting it there first.
- What `mobile.hello` advertises lives in `MOBILE_HELLO_CAPABILITIES` (`rust/alera-cli/src/terminal_host/server/mobile_gateway_surface.rs`), which is a different list from the one `status.get` returns. A capability the app feature-detects MUST be in the hello list: an omission is invisible, leaves every phone permanently on the older code path, and has no version to blame because the runtime requires an exact `aleraMobileProtocolVersion` match.
- Explorer, Search, Source Control, and Pull Request are additive (`mobileExplorerV1`, `mobileWorkspaceSearchV1`, `mobileSourceControlV1`, `mobilePullRequestV1`). Older phones ignore the capabilities; older hosts omit the bottom navigation. Source Control writes (stage, unstage, discard, commit, amend, fetch, pull, push, sync, stash, stash pop, branch checkout and create) are host verbs under `mobileSourceControlWritesV1`, backed by the same `alera_core::source_control` code the desktop bridge calls. `mobile.git.status` carries the actions the runtime allows (`actions`, `primaryAction`, and per-entry `canStage` / `canUnstage` / `canDiscard`) and every write answers with a fresh snapshot, so the phone MUST render those flags rather than re-deriving git rules. The only local rule is that Commit needs a non-blank message. Git failures carry an `errorCode` (`git<Kind>`, `sourceControlBusy`) next to a desktop-worded `error`. A host without the capability stays read-only. Generate Commit Message runs on the runtime through `aiText.commitMessage.generate` (`aiTextCommitMessageV1`), with the desktop prompt and the runtime's AI Assist settings; the phone offers it only when the status snapshot reports `aiCommitMessageEnabled`. Pull Request v1 shows current-branch identity, title, state, checks, and a read-only conversation through `gh` plus `linkedReview.find`: conversation comments, review summaries, and diff review threads (path, line, resolved) rendered as Markdown and grouped the same way desktop groups them, with resolved threads and, past five checks, passing checks collapsed. The comment fields beyond `id`, `author`, `body`, `createdAt`, and `url` (`kind`, `source`, `path`, `line`, `resolved`, `threadId`) are additive and add no capability; a phone on an older host shows every comment as a conversation comment. With `mobilePullRequestActionsV1` the runtime also accepts GitHub writes: `mobile.pullRequest.comment` (a conversation comment, or a thread reply through `replyToCommentId`), `commentUpdate` (only the author's own comments, per `canEdit`), `merge` (only the snapshot's `mergeMethods`, which are repository settings intersected with the base branch rulesets), `draftStatus`, `close`, `link`, `unlink` (a dismissal of that exact review, never a delete), and `create` (for a branch that is already pushed). Each runs the desktop's `gh` command on the runtime, answers with a fresh snapshot, and returns an additive `errorCode` for known `gh` failures; writes to one workspace are serialized, and workspaces on a remote SSH host are refused. The raw `linkedReview.upsert` and `linkedReview.remove` verbs stay off the allowlist. The snapshot adds `viewerLogin`, `canComment`, per-comment `canEdit`, `mergeMethods` and `mergeMethodsError`, `baseBranches`, `suggestedBaseBranch`, `aiAssistEnabled`, and `suggestedReview` for a review the user unlinked. Create offers Generate With AI through `aiText.pullRequestDetails.generate` (`aiTextPullRequestDetailsV1`), the desktop's pull request prompt over the range from the base branch to HEAD, and only when the snapshot reports `aiAssistEnabled`. Ship (`mobile.pullRequest.ship`, `mobilePullRequestShipV1`) is the desktop's Ship in one runtime request: it stages all changes or keeps the staged ones, writes the commit message with AI Assist, moves work on a shared base branch to a `ship/...` branch and puts that base back on its remote tip, commits, pushes, and opens and links the pull request. It runs on the runtime rather than as a sequence of phone requests so a phone that sleeps halfway cannot leave a commit without its pull request, it holds the same per-workspace lock as the Source Control writes, and a failure after the commit says the commit already happened. GitLab and Azure DevOps stay on desktop. Markdown files (`.md`, `.mdx`) open as a view-only rendered preview with a source toggle, and desktop Markdown viewer tabs open that preview from the tab strip; it reuses `mobile.workspaceFile.read` for the file and its relative images, mirrors the desktop link and image URI policy in `workspace_markdown_uri_policy.dart`, and adds no capability. Search also replaces through additive `mobileWorkspaceReplaceV1`: `mobile.workspaceSearch.run` gains `replacement`, `preserveCase`, and a `requestId` that `mobile.workspaceSearch.cancel` aborts, and `mobile.workspaceSearch.replace` writes the files. A replace carries each affected file's `contentToken` from the last search, so the host skips a file that changed since the phone saw it instead of overwriting it, and Replace All is refused while results are truncated. The host namespaces cancellation ids per client. Search view as tree, search ignored files, and the Source Control view and group modes are shared with the desktop through `workbenchViewPrefs` (`gitDiffViewMode`, `gitDiffGroupMode`, `searchViewAsTree`, `searchIncludeIgnored`); the host backfills any of those keys a client omits, so an older client does not reset them. Out of scope for this surface: terminal layout (#638), fake-resize refresh (#640), and attaching comments on files or diffs to send to an agent (#648).
- Explorer, Search, Source Control, and Pull Request are additive (`mobileExplorerV1`, `mobileWorkspaceSearchV1`, `mobileSourceControlV1`, `mobilePullRequestV1`). Older phones ignore the capabilities; older hosts omit the bottom navigation. Source Control writes (stage, unstage, discard, commit, amend, fetch, pull, push, sync, stash, stash pop, branch checkout and create) are host verbs under `mobileSourceControlWritesV1`, backed by the same `alera_core::source_control` code the desktop bridge calls. `mobile.git.status` carries the actions the runtime allows (`actions`, `primaryAction`, and per-entry `canStage` / `canUnstage` / `canDiscard`) and every write answers with a fresh snapshot, so the phone MUST render those flags rather than re-deriving git rules. The only local rule is that Commit needs a non-blank message. Git failures carry an `errorCode` (`git<Kind>`, `sourceControlBusy`) next to a desktop-worded `error`. A host without the capability stays read-only. Generate Commit Message runs on the runtime through `aiText.commitMessage.generate` (`aiTextCommitMessageV1`), with the desktop prompt and the runtime's AI Assist settings; the phone offers it only when the status snapshot reports `aiCommitMessageEnabled`. Pull Request v1 shows current-branch identity, title, state, checks, and a read-only conversation through `gh` plus `linkedReview.find`: conversation comments, review summaries, and diff review threads (path, line, resolved) rendered as Markdown and grouped the same way desktop groups them, with resolved threads and, past five checks, passing checks collapsed. The comment fields beyond `id`, `author`, `body`, `createdAt`, and `url` (`kind`, `source`, `path`, `line`, `resolved`, `threadId`) are additive and add no capability; a phone on an older host shows every comment as a conversation comment. With `mobilePullRequestActionsV1` the runtime also accepts GitHub writes: `mobile.pullRequest.comment` (a conversation comment, or a thread reply through `replyToCommentId`), `commentUpdate` (only the author's own comments, per `canEdit`), `merge` (only the snapshot's `mergeMethods`, which are repository settings intersected with the base branch rulesets), `draftStatus`, `close`, `link`, `unlink` (a dismissal of that exact review, never a delete), and `create` (for a branch that is already pushed). Each runs the desktop's `gh` command on the runtime, answers with a fresh snapshot, and returns an additive `errorCode` for known `gh` failures; writes to one workspace are serialized, and workspaces on a remote SSH host are refused. The raw `linkedReview.upsert` and `linkedReview.remove` verbs stay off the allowlist. The snapshot adds `viewerLogin`, `canComment`, per-comment `canEdit`, `mergeMethods` and `mergeMethodsError`, `baseBranches`, `suggestedBaseBranch`, `aiAssistEnabled`, and `suggestedReview` for a review the user unlinked. Create offers Generate With AI through `aiText.pullRequestDetails.generate` (`aiTextPullRequestDetailsV1`), the desktop's pull request prompt over the range from the base branch to HEAD, and only when the snapshot reports `aiAssistEnabled`. Ship (`mobile.pullRequest.ship`, `mobilePullRequestShipV1`) is the desktop's Ship in one runtime request: it stages all changes or keeps the staged ones (the phone hides All/Staged when workspace-root git status has no uncommitted changes and ships staged), writes the commit message with AI Assist, moves work on a shared base branch to a `ship/...` branch and puts that base back on its remote tip, commits, pushes, and opens and links the pull request. It runs on the runtime rather than as a sequence of phone requests so a phone that sleeps halfway cannot leave a commit without its pull request, it holds the same per-workspace lock as the Source Control writes, and a failure after the commit says the commit already happened. GitLab and Azure DevOps stay on desktop. Markdown files (`.md`, `.mdx`) open as a view-only rendered preview with a source toggle, and desktop Markdown viewer tabs open that preview from the tab strip; it reuses `mobile.workspaceFile.read` for the file and its relative images, mirrors the desktop link and image URI policy in `workspace_markdown_uri_policy.dart`, and adds no capability. Search also replaces through additive `mobileWorkspaceReplaceV1`: `mobile.workspaceSearch.run` gains `replacement`, `preserveCase`, and a `requestId` that `mobile.workspaceSearch.cancel` aborts, and `mobile.workspaceSearch.replace` writes the files. A replace carries each affected file's `contentToken` from the last search, so the host skips a file that changed since the phone saw it instead of overwriting it, and Replace All is refused while results are truncated. The host namespaces cancellation ids per client. Search view as tree, search ignored files, and the Source Control view and group modes are shared with the desktop through `workbenchViewPrefs` (`gitDiffViewMode`, `gitDiffGroupMode`, `searchViewAsTree`, `searchIncludeIgnored`); the host backfills any of those keys a client omits, so an older client does not reset them. Out of scope for this surface: terminal layout (#638), fake-resize refresh (#640), and attaching comments on files or diffs to send to an agent (#648).
- Linked issues are additive (`linkedIssuesV1`): the row shows the issue glyph, the actions sheet links, opens, changes, and unlinks it, and New Workspace offers the issue field. The phone calls `issue.fetch` and `linkedIssue.*` on the paired runtime, which runs the forge CLI; it never fetches from a forge itself.
- Explorer carries the desktop's non-mutating actions (#751): Hide Ignored Files through the existing `hideIgnored` field of `mobile.workspaceExplorer.list`, Collapse All, a Refresh that keeps open folders open, Copy Path / Copy Relative Path, Comment on File, and Use As / Clear Source Control Root. File mutation (new, rename, delete, duplicate, cut and paste, editing) stays on desktop because the host has no verbs for it. The ignore mode and the Source Control root are phone-local per workspace (`ExplorerPreferencesController`, `SharedPreferencesAsync`), not shared view prefs: the desktop keeps both locally too, so sharing them would make the two surfaces overwrite each other. Copy Path joins with the host's separator taken from the workspace root (`hostAbsolutePath`), never the phone's POSIX path context.
- The Source Control root is negotiated through `mobileSourceControlRootV1`: `mobile.git.status` and `mobile.git.diff` accept an additive `relativeRoot`, resolved through `contained_workspace_relative_path` so `..`, absolute paths, and symlinks cannot leave the workspace. The client sends the field only when the capability is advertised, because an older host would silently answer for the workspace root, and it validates a folder with `mobile.git.status` before saving it. A nested root must be its own repository (`Repository::open`, not discovery), matching desktop.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -197,13 +197,21 @@ class const PullRequestPanelActions({
);
}

Future<void> ship(BuildContext context, MobilePullRequestSnapshot snapshot) {
Future<void> ship(
BuildContext context,
MobilePullRequestSnapshot snapshot,
) async {
final controller = _controller;
final askWorkingTreeScope = await _askWorkingTreeScope();
if (!context.mounted) {
return;
}
return showShipPullRequestSheet(
context,
headBranch: snapshot.branch,
baseBranches: snapshot.baseBranches,
suggestedBaseBranch: snapshot.suggestedBaseBranch,
askWorkingTreeScope: askWorkingTreeScope,
onSubmit: (input) => controller.run(
.ship,
(client) =>
Expand All @@ -212,6 +220,22 @@ class const PullRequestPanelActions({
);
}

/// Statuses the workspace root, not the Source Control panel's nested root.
/// Host Ship always plans against `workspace.path`, matching desktop.
Future<bool> _askWorkingTreeScope() async {
try {
final client = await ref.read(workspaceClientProvider(hostId).future);
if (client case final MobileWorkspacePanelsClient panels
when panels.supportsSourceControl) {
final snapshot = await panels.gitStatus(workspaceId);
return shipShowsWorkingTreeScopeChoice(snapshot);
}
return true;
} on Object {
return true;
}
}

void _report(ScaffoldMessengerState messenger, String? error) {
if (error != null && messenger.mounted) {
messenger.showSnackBar(SnackBar(content: Text(error)));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,23 @@ import 'package:alera_mobile/src/design_system/feedback/alera_notice.dart';
import 'package:alera_mobile/src/design_system/forms/alera_dropdown_field.dart';
import 'package:alera_mobile/src/design_system/icons/alera_icons.dart';
import 'package:alera_mobile/src/features/runtime/domain/mobile_pull_request_actions.dart';
import 'package:alera_mobile/src/features/runtime/domain/mobile_source_control.dart';
import 'package:flutter/material.dart';

/// Whether Ship should ask All vs Staged. A loaded snapshot with no entries
/// ships staged without the choice, matching desktop. A missing snapshot
/// (status unread or failed) keeps the control.
bool shipShowsWorkingTreeScopeChoice(MobileGitStatusSnapshot? snapshot) {
return snapshot == null || snapshot.entries.isNotEmpty;
}

/// Opens [ShipPullRequestSheet].
Future<void> showShipPullRequestSheet(
BuildContext context, {
required String? headBranch,
required List<String> baseBranches,
required String? suggestedBaseBranch,
bool askWorkingTreeScope = true,
required Future<String?> Function(MobilePullRequestShipInput input) onSubmit,
}) {
return showModalBottomSheet<void>(
Expand All @@ -21,6 +30,7 @@ Future<void> showShipPullRequestSheet(
headBranch: headBranch,
baseBranches: baseBranches,
suggestedBaseBranch: suggestedBaseBranch,
askWorkingTreeScope: askWorkingTreeScope,
onSubmit: onSubmit,
),
);
Expand All @@ -34,6 +44,7 @@ class const ShipPullRequestSheet({
required final String? headBranch,
required final List<String> baseBranches,
required final String? suggestedBaseBranch,
final bool askWorkingTreeScope = true,
required final Future<String?> Function(MobilePullRequestShipInput input)
onSubmit,
}) extends StatefulWidget {
Expand All @@ -43,11 +54,17 @@ class const ShipPullRequestSheet({

class _ShipPullRequestSheetState extends State<ShipPullRequestSheet> {
late String? _base = _initialBase();
bool _stagedOnly = false;
late bool _stagedOnly;
bool _draft = false;
bool _submitting = false;
String? _error;

@override
void initState() {
super.initState();
_stagedOnly = !widget.askWorkingTreeScope;
}

String? _initialBase() {
final suggested = widget.suggestedBaseBranch;
if (suggested != null && widget.baseBranches.contains(suggested)) {
Expand Down Expand Up @@ -143,21 +160,26 @@ class _ShipPullRequestSheetState extends State<ShipPullRequestSheet> {
],
onChanged: (branch) => setState(() => _base = branch),
),
const SizedBox(height: AleraTokens.space12),
SegmentedButton<bool>(
segments: const <ButtonSegment<bool>>[
ButtonSegment<bool>(value: false, label: Text('All Changes')),
ButtonSegment<bool>(
value: true,
label: Text('Staged Changes'),
),
],
selected: <bool>{_stagedOnly},
onSelectionChanged: _submitting
? null
: (selection) =>
setState(() => _stagedOnly = selection.first),
),
if (widget.askWorkingTreeScope) ...<Widget>[
const SizedBox(height: AleraTokens.space12),
SegmentedButton<bool>(
segments: const <ButtonSegment<bool>>[
ButtonSegment<bool>(
value: false,
label: Text('All Changes'),
),
ButtonSegment<bool>(
value: true,
label: Text('Staged Changes'),
),
],
selected: <bool>{_stagedOnly},
onSelectionChanged: _submitting
? null
: (selection) =>
setState(() => _stagedOnly = selection.first),
),
],
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Create As Draft'),
Expand Down
Loading
Loading