diff --git a/.github/workflows/docker-builder.yml b/.github/workflows/docker-builder.yml index e2ff471..6f1eed8 100644 --- a/.github/workflows/docker-builder.yml +++ b/.github/workflows/docker-builder.yml @@ -9,6 +9,10 @@ on: description: 'Tag Name (leave empty for default latest)' required: false default: 'latest' + +permissions: + contents: read + jobs: build-and-push: runs-on: ubuntu-latest diff --git a/native/lib/features/ai_agent/agent_composer.dart b/native/lib/features/ai_agent/agent_composer.dart index e6edb05..4f0f761 100644 --- a/native/lib/features/ai_agent/agent_composer.dart +++ b/native/lib/features/ai_agent/agent_composer.dart @@ -27,46 +27,8 @@ class _AgentComposer extends ConsumerWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - TextField( - controller: controller, - minLines: 1, - maxLines: 5, - textInputAction: TextInputAction.newline, - decoration: InputDecoration( - hintText: l.tr('agent.inputHint'), - filled: true, - fillColor: context.colors.chip, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular( - AgentUiTokens.radiusMedium, - ), - borderSide: BorderSide.none, - ), - suffixIcon: state.conversation.running - ? IconButton( - tooltip: l.tr('agent.stop'), - onPressed: ref - .read(agentControllerProvider.notifier) - .stop, - icon: Icon( - Icons.stop_circle, - color: context.colors.danger, - ), - ) - : ValueListenableBuilder( - valueListenable: controller, - builder: (context, value, _) => IconButton( - tooltip: l.tr('agent.send'), - onPressed: state.canSendDraft(value.text) - ? onSend - : null, - icon: const Icon(Icons.arrow_upward_rounded), - ), - ), - ), - ), - const SizedBox(height: 8), Row( + key: const Key('agent-composer-options'), children: [ Expanded( child: _ComposerPickerButton( @@ -105,6 +67,46 @@ class _AgentComposer extends ConsumerWidget { ), ], ), + const SizedBox(height: 8), + TextField( + key: const Key('agent-composer-input'), + controller: controller, + minLines: 1, + maxLines: 5, + textInputAction: TextInputAction.newline, + decoration: InputDecoration( + hintText: l.tr('agent.inputHint'), + filled: true, + fillColor: context.colors.chip, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular( + AgentUiTokens.radiusMedium, + ), + borderSide: BorderSide.none, + ), + suffixIcon: state.conversation.running + ? IconButton( + tooltip: l.tr('agent.stop'), + onPressed: ref + .read(agentControllerProvider.notifier) + .stop, + icon: Icon( + Icons.stop_circle, + color: context.colors.danger, + ), + ) + : ValueListenableBuilder( + valueListenable: controller, + builder: (context, value, _) => IconButton( + tooltip: l.tr('agent.send'), + onPressed: state.canSendDraft(value.text) + ? onSend + : null, + icon: const Icon(Icons.arrow_upward_rounded), + ), + ), + ), + ), ], ), ), diff --git a/native/lib/features/ai_agent/agent_overlay.dart b/native/lib/features/ai_agent/agent_overlay.dart index 771f2ca..9ba60d7 100644 --- a/native/lib/features/ai_agent/agent_overlay.dart +++ b/native/lib/features/ai_agent/agent_overlay.dart @@ -91,27 +91,36 @@ class _AgentGlobalOverlayState extends ConsumerState { final bottomInset = media.viewInsets.bottom > 0 ? media.viewInsets.bottom + AgentUiTokens.edgeGap : media.padding.bottom + 84; - final maxX = constraints.maxWidth - size - AgentUiTokens.edgeGap; + const minX = AgentUiTokens.entryHorizontalInset; + final maxX = + (constraints.maxWidth - + size - + AgentUiTokens.entryHorizontalInset) + .clamp(minX, double.infinity) + .toDouble(); final maxY = (constraints.maxHeight - size - bottomInset) .clamp(topInset, double.infinity) .toDouble(); final range = (maxY - topInset) .clamp(1.0, double.infinity) .toDouble(); - _lastX = - (_dragX ?? (_side == 'left' ? AgentUiTokens.edgeGap : maxX)) - .clamp(AgentUiTokens.edgeGap, maxX) - .toDouble(); + _lastX = (_dragX ?? (_side == 'left' ? minX : maxX)) + .clamp(minX, maxX) + .toDouble(); _lastY = (_dragY ?? topInset + range * _yFraction) .clamp(topInset, maxY) .toDouble(); return Stack( children: [ - Positioned( + AnimatedPositioned( left: _lastX, top: _lastY, width: size, height: size, + duration: _dragX == null + ? AgentUiTokens.entrySnapDuration + : Duration.zero, + curve: Curves.easeOutCubic, child: FocusableActionDetector( onShowFocusHighlight: (value) { if (mounted) setState(() => _entryFocused = value); @@ -148,7 +157,7 @@ class _AgentGlobalOverlayState extends ConsumerState { onPanUpdate: (details) { setState(() { _dragX = (_dragX! + details.delta.dx) - .clamp(AgentUiTokens.edgeGap, maxX) + .clamp(minX, maxX) .toDouble(); _dragY = (_dragY! + details.delta.dy) .clamp(topInset, maxY) diff --git a/native/lib/features/ai_agent/agent_ui_tokens.dart b/native/lib/features/ai_agent/agent_ui_tokens.dart index 018aec3..445d09e 100644 --- a/native/lib/features/ai_agent/agent_ui_tokens.dart +++ b/native/lib/features/ai_agent/agent_ui_tokens.dart @@ -1,6 +1,8 @@ abstract final class AgentUiTokens { static const compactBreakpoint = 720.0; static const entrySize = 56.0; + static const entryHorizontalInset = 0.0; + static const entrySnapDuration = Duration(milliseconds: 320); static const edgeGap = 12.0; static const wideWidth = 480.0; static const wideHeight = 720.0; diff --git a/native/test/features/ai_agent/agent_overlay_test.dart b/native/test/features/ai_agent/agent_overlay_test.dart index ece8541..73c6368 100644 --- a/native/test/features/ai_agent/agent_overlay_test.dart +++ b/native/test/features/ai_agent/agent_overlay_test.dart @@ -182,11 +182,42 @@ void main() { await tester.pump(); final entry = find.byKey(const Key('agent-global-entry')); + final logicalWidth = + tester.view.physicalSize.width / tester.view.devicePixelRatio; + expect(tester.getTopRight(entry).dx, closeTo(logicalWidth, 0.01)); + await tester.drag(entry, const Offset(-700, -120)); await tester.pumpAndSettle(); expect(prefs.agentEntrySide, 'left'); expect(prefs.agentEntryY, inInclusiveRange(0.05, 0.95)); - expect(tester.getTopLeft(entry).dx, lessThan(20)); + expect(tester.getTopLeft(entry).dx, closeTo(0, 0.01)); + }); + + testWidgets('animates to the nearest edge after release', (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(390, 844); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + final prefs = await storage(); + final observer = AgentNavigationObserver(); + addTearDown(observer.dispose); + await tester.pumpWidget(app(storage: prefs, observer: observer)); + await tester.pump(); + + final entry = find.byKey(const Key('agent-global-entry')); + await tester.drag(entry, const Offset(-200, 0)); + await tester.pump(); + final releaseX = tester.getTopLeft(entry).dx; + expect(releaseX, greaterThan(0)); + + await tester.pump(const Duration(milliseconds: 80)); + + final transitioningX = tester.getTopLeft(entry).dx; + expect(transitioningX, greaterThan(0)); + expect(transitioningX, lessThan(releaseX)); + + await tester.pumpAndSettle(); + expect(tester.getTopLeft(entry).dx, closeTo(0, 0.01)); }); }