diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml index eac75225..58bb53b8 100644 --- a/.github/workflows/deploy-site.yml +++ b/.github/workflows/deploy-site.yml @@ -6,12 +6,12 @@ on: branches: [main, master] paths: - "apps/site/**" - - "apps/web/**" + - "apps/flutter/**" - "packages/**" - "scripts/check-release-publication.mjs" + - "scripts/build-demo.mjs" - "scripts/deploy-site.mjs" - "scripts/deploy-demo.mjs" - - "scripts/deploy-site-bundle.mjs" - "scripts/dispatch-site-deployment.mjs" - "scripts/generate-release-manifest.mjs" - "scripts/inspect-linux-update.mjs" @@ -41,6 +41,61 @@ concurrency: cancel-in-progress: false jobs: + demo: + if: ${{ github.event_name == 'push' }} + runs-on: ubuntu-24.04 + timeout-minutes: 30 + environment: + name: production + url: https://t4code.net/demo + steps: + - name: Check out current demo source + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + ref: ${{ github.sha }} + persist-credentials: false + + - name: Install pnpm + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + with: + version: 11.10.0 + + - name: Install Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 24.13.1 + + - name: Install Flutter + uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 + with: + flutter-version: 3.44.6 + channel: stable + cache: true + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Authenticate to AWS with GitHub OIDC + uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4 + with: + role-to-assume: ${{ vars.AWS_ROLE_ARN }} + aws-region: us-east-1 + + - name: Confirm the live demo permits the Flutter renderer + shell: bash + run: | + set -euo pipefail + curl --fail --silent --show-error --head https://t4code.net/demo \ + | tr -d '\r' \ + | grep -i '^content-security-policy:' \ + | grep -Fq "'wasm-unsafe-eval'" + + - name: Build and deploy current Flutter demo + env: + T4_SITE_BUCKET: ${{ vars.T4_SITE_BUCKET }} + T4_CLOUDFRONT_DISTRIBUTION_ID: ${{ vars.T4_CLOUDFRONT_DISTRIBUTION_ID }} + run: pnpm deploy:demo + deploy: if: ${{ github.event_name != 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/') }} runs-on: ubuntu-24.04 @@ -157,18 +212,9 @@ jobs: ref: ${{ steps.immutable_source.outputs.source_sha }} persist-credentials: false - - name: Check out trusted demo source - if: ${{ steps.published_release.outcome == 'success' || steps.existing_release.outcome == 'success' }} - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - ref: ${{ steps.source.outputs.trusted_sha }} - path: .trusted-demo-source - persist-credentials: false - name: Install dependencies if: ${{ steps.published_release.outcome == 'success' || steps.existing_release.outcome == 'success' }} - run: | - pnpm install --frozen-lockfile - pnpm --dir .trusted-demo-source install --frozen-lockfile + run: pnpm install --frozen-lockfile - name: Authenticate to AWS with GitHub OIDC if: ${{ steps.published_release.outcome == 'success' || steps.existing_release.outcome == 'success' }} @@ -177,11 +223,10 @@ jobs: role-to-assume: ${{ vars.AWS_ROLE_ARN }} aws-region: us-east-1 - - name: Build and deploy static site + - name: Build and deploy immutable release site if: ${{ steps.published_release.outcome == 'success' || steps.existing_release.outcome == 'success' }} env: GH_TOKEN: ${{ github.token }} T4_SITE_BUCKET: ${{ vars.T4_SITE_BUCKET }} - T4_IMMUTABLE_SITE_SOURCE: ${{ github.workspace }} T4_CLOUDFRONT_DISTRIBUTION_ID: ${{ vars.T4_CLOUDFRONT_DISTRIBUTION_ID }} - run: pnpm --dir .trusted-demo-source deploy:site-bundle + run: pnpm deploy:site diff --git a/.gitignore b/.gitignore index 6165d3b8..1e0549d8 100644 --- a/.gitignore +++ b/.gitignore @@ -25,5 +25,6 @@ dist-electron/ coverage/ playwright-report/ test-results/ +/output/playwright/ *.log .todos/ diff --git a/README.md b/README.md index 50ed14db..28f27aa1 100644 --- a/README.md +++ b/README.md @@ -145,9 +145,9 @@ native release checks. ``` apps/desktop Electron main process: window, local OMP discovery, host lifecycle, pairing, credential storage -apps/web React UI (Vite): sessions, composer, panes, settings -apps/flutter Native Android/iOS/macOS client: responsive UI, +apps/flutter Canonical Android/iOS/macOS/web UI: responsive workspace, secure credentials, lifecycle, updates, OMP service controls +apps/web Legacy React compatibility UI for the Electron client packages/ client, protocol, host-wire, host-service, remote, service-manager, ui ``` diff --git a/apps/flutter/README.md b/apps/flutter/README.md index 32e60f38..eb5a72d0 100644 --- a/apps/flutter/README.md +++ b/apps/flutter/README.md @@ -22,6 +22,12 @@ flutter test integration_test/app_smoke_test.dart -d build directly to a host. Development credentials are intentionally volatile. Release builds use platform secure storage. +The public `https://t4code.net/demo/` preview is a read-only Flutter build with +local display data. From the repository root, `pnpm build:demo` creates that +subpath build in `apps/site/dist/demo`; it does not connect to a host or store +credentials. Demo publication follows current `main` independently of desktop +release tags. + The device smoke harness launches the real native shell, waits for persistent storage and platform initialization, and opens host management. CI runs it on an Android emulator and an iOS simulator; local device IDs come from diff --git a/apps/flutter/lib/main.dart b/apps/flutter/lib/main.dart index a4389f9e..62c77b46 100644 --- a/apps/flutter/lib/main.dart +++ b/apps/flutter/lib/main.dart @@ -6,12 +6,18 @@ import 'package:flutter/foundation.dart'; import 'src/client/app_state.dart'; import 'src/client/t4_client_controller.dart'; import 'src/client/transcript_tail_store.dart'; +import 'src/demo/demo_app.dart'; import 'src/host/app_preferences.dart'; import 'src/host/persistent_host_stores.dart'; import 'src/platform/platform_lifecycle_controller.dart'; import 'src/ui/t4_app.dart'; void main() { + const demoMode = bool.fromEnvironment('T4_DEMO_MODE'); + if (demoMode) { + runApp(T4DemoApp()); + return; + } runApp(T4Bootstrap(developmentEndpoint: _developmentEndpoint())); } diff --git a/apps/flutter/lib/src/demo/demo_app.dart b/apps/flutter/lib/src/demo/demo_app.dart new file mode 100644 index 00000000..20055f70 --- /dev/null +++ b/apps/flutter/lib/src/demo/demo_app.dart @@ -0,0 +1,561 @@ +import 'dart:typed_data'; + +import 'package:flutter/widgets.dart'; + +import '../client/app_state.dart'; +import '../host/host_profile.dart'; +import '../protocol/models.dart'; +import '../ui/t4_app.dart'; + +/// Read-only public preview of the canonical Flutter client. +/// +/// The demo deliberately uses local display data and never opens a network +/// connection or stores credentials. +final class T4DemoApp extends StatelessWidget { + const T4DemoApp({super.key}); + + static const T4Actions _actions = _DemoActions(); + + @override + Widget build(BuildContext context) => T4App( + state: demoViewState, + actions: _actions, + credentialsAreVolatile: false, + demoMode: true, + ); +} + +final HostProfile _demoProfile = HostProfile.parseTailnetAddress( + 'https://demo.t4code.ts.net', +); + +/// Fixed timestamp for all demo usage data: 2026-07-21T08:00:00Z. +const int _demoGeneratedAtMs = 1784620800000; + +final T4ViewState demoViewState = T4ViewState( + connectionPhase: ConnectionPhase.ready, + hostDirectory: HostDirectory.empty().upsert(_demoProfile), + authenticationPhase: AuthenticationPhase.paired, + targetConfigured: true, + grantedCapabilities: t4RequestedCapabilities.toSet(), + grantedFeatures: t4RequestedFeatures.toSet(), + selectedSessionId: 'sess-settings', + sessions: const [ + SessionSummary( + hostId: 'demo-host', + sessionId: 'sess-settings', + projectId: 'project-t4', + projectName: 'T4 Code', + title: 'Fix quick-open stale results', + revision: 'demo-revision-3', + status: 'idle', + updatedAt: '2026-07-21T08:00:00Z', + modelSelector: 'openai-codex/gpt-5.6-sol', + modelDisplayName: 'GPT-5.6 Sol', + thinking: 'high', + thinkingSupported: true, + thinkingLevels: ['off', 'medium', 'high'], + fastAvailable: true, + ), + SessionSummary( + hostId: 'demo-host', + sessionId: 'sess-runtime', + projectId: 'project-t4', + projectName: 'T4 Code', + title: 'Flutter runtime integration', + revision: 'demo-revision-2', + status: 'idle', + updatedAt: '2026-07-21T07:40:00Z', + ), + SessionSummary( + hostId: 'demo-host', + sessionId: 'sess-release', + projectId: 'project-t4', + projectName: 'T4 Code', + title: 'Release readiness', + revision: 'demo-revision-1', + status: 'idle', + updatedAt: '2026-07-21T07:10:00Z', + ), + SessionSummary( + hostId: 'demo-host', + sessionId: 'sess-omp-advisor', + projectId: 'project-omp', + projectName: 'omp', + title: 'Advisor routing selectors', + revision: 'demo-revision-8', + status: 'running widget tests', + updatedAt: '2026-07-21T07:58:00Z', + working: true, + modelSelector: 'openai-codex/gpt-5.6-sol', + modelDisplayName: 'GPT-5.6 Sol', + ), + SessionSummary( + hostId: 'demo-host', + sessionId: 'sess-omp-usage', + projectId: 'project-omp', + projectName: 'omp', + title: 'Usage meter polish', + revision: 'demo-revision-5', + status: 'closed', + updatedAt: '2026-07-18T16:45:00Z', + archivedAt: '2026-07-18T16:45:00Z', + ), + ], + messages: const [ + TranscriptMessage( + id: 'demo-message-1', + role: MessageRole.user, + text: + 'Quick open keeps showing results from the previous session after I ' + 'switch projects. Can you fix it?', + ), + TranscriptMessage( + id: 'demo-message-2', + role: MessageRole.assistant, + reasoning: + 'Reproduced the report: open quick open in session A, switch to ' + 'session B, reopen the palette — the first frame still lists paths ' + 'from A. The result cache in quick_open.dart is keyed by query text ' + 'only, so it survives session switches. Either the cache needs the ' + 'session id in its key, or it should be dropped when the selected ' + 'session changes. Scoping the key is smaller and keeps warm results ' + 'per session.', + text: + 'Reproduced it. The palette caches results in `_resultCache`, keyed ' + 'by the query string alone, so switching sessions replays stale ' + 'paths from the previous workspace. Let me confirm the shape of the ' + 'cache before proposing a fix.', + ), + TranscriptMessage( + id: 'demo-message-3', + role: MessageRole.tool, + kind: TranscriptKind.tool, + text: '', + toolName: 'files.read', + toolTitle: 'Read lib/src/quick_open.dart', + toolArguments: '{"path": "lib/src/quick_open.dart"}', + toolOutput: + 'final Map> _resultCache =\n' + ' >{};\n' + '\n' + 'Future> search(String query) async {\n' + ' final cached = _resultCache[query];\n' + ' if (cached != null) return cached;\n' + ' ...\n' + '}', + toolSucceeded: true, + ), + TranscriptMessage( + id: 'demo-message-4', + role: MessageRole.assistant, + text: + 'Confirmed — the cache never sees the session change. Two viable ' + 'fixes:\n' + '\n' + '| Approach | Blast radius | Warm-cache behavior |\n' + '| --- | --- | --- |\n' + '| Scope keys by `(sessionId, query)` | One file | Keeps per-session warm results |\n' + '| Clear cache on `selectSession` | Two files | Cold palette after every switch |\n' + '\n' + '> Scoping the key is the smaller change and preserves warm results ' + 'when you switch back to a session.\n' + '\n' + 'Checking for other readers of `_resultCache` before touching it.', + ), + TranscriptMessage( + id: 'demo-message-5', + role: MessageRole.tool, + kind: TranscriptKind.tool, + text: '', + toolName: 'terminal.run', + toolTitle: 'grep -rn _resultCache lib/', + toolArguments: '{"command": "grep -rn _resultCache lib/"}', + toolOutput: + 'lib/src/quick_open.dart:18:final Map> _resultCache =\n' + 'lib/src/quick_open.dart:22: final cached = _resultCache[query];\n' + 'lib/src/quick_open.dart:31: _resultCache[query] = paths;', + toolSucceeded: true, + ), + TranscriptMessage( + id: 'demo-message-6', + role: MessageRole.assistant, + text: + 'Only quick_open.dart touches the cache, so the scoped key is safe. ' + 'Proposed change:\n' + '\n' + '```dart\n' + 'final Map<(String, String), List> _resultCache =\n' + ' <(String, String), List>{};\n' + '\n' + 'Future> search(String sessionId, String query) async {\n' + ' final key = (sessionId, query);\n' + ' final cached = _resultCache[key];\n' + ' if (cached != null) return cached;\n' + ' final paths = await _actions.searchProjectFiles(query);\n' + ' _resultCache[key] = paths.paths;\n' + ' return paths.paths;\n' + '}\n' + '```\n' + '\n' + 'The palette already knows the selected session, so the callsite ' + 'change is one line.', + ), + TranscriptMessage( + id: 'demo-message-7', + role: MessageRole.user, + text: + 'Go with the session-scoped cache. Run the tests before you wrap up.', + ), + TranscriptMessage( + id: 'demo-message-8', + role: MessageRole.tool, + kind: TranscriptKind.tool, + text: '', + toolName: 'terminal.run', + toolTitle: 'flutter test test/quick_open_test.dart', + toolArguments: '{"command": "flutter test test/quick_open_test.dart"}', + toolOutput: + '00:03 +11: quick open scopes cached results per session\n' + '00:04 +12: All tests passed!', + toolSucceeded: true, + ), + TranscriptMessage( + id: 'demo-message-9', + role: MessageRole.assistant, + text: + 'Applied and verified. To reproduce the check locally:\n' + '\n' + '```bash\n' + 'cd apps/flutter\n' + 'flutter analyze\n' + 'flutter test test/quick_open_test.dart\n' + '```\n' + '\n' + 'Quick open now keys its cache by `(sessionId, query)`, so switching ' + 'projects can no longer surface another workspace\u2019s paths.', + ), + TranscriptMessage( + id: 'demo-message-10', + role: MessageRole.system, + kind: TranscriptKind.notice, + text: + 'Turn complete \u00b7 2 files changed \u00b7 12 tests passed \u00b7 ' + 'quick-open results are now scoped per session.', + ), + ], + attentionItems: [ + AttentionItem( + key: 'demo-attention-approval', + kind: AttentionKind.approval, + sessionId: 'sess-omp-advisor', + sessionTitle: 'Advisor routing selectors', + revision: 'demo-revision-8', + title: 'Run pnpm test?', + summary: + 'The agent wants to run `pnpm test` in packages/advisor before ' + 'committing the selector change.', + at: DateTime.utc(2026, 7, 21, 7, 58), + choices: const [ + AttentionChoice(id: 'allow', label: 'Allow'), + AttentionChoice(id: 'deny', label: 'Deny'), + ], + actionable: true, + ), + AttentionItem( + key: 'demo-attention-completed', + kind: AttentionKind.completed, + sessionId: 'sess-settings', + sessionTitle: 'Fix quick-open stale results', + revision: 'demo-revision-3', + title: 'Quick-open fix landed', + summary: + 'Session-scoped result cache applied; analyzer clean and 12 tests ' + 'passing.', + at: DateTime.utc(2026, 7, 21, 8), + ), + ], + agentActivities: [ + AgentActivity( + agentId: 'demo-agent-tests', + sessionId: 'sess-omp-advisor', + label: 'Widget test sweep', + status: 'running', + updatedAt: DateTime.utc(2026, 7, 21, 7, 59), + progress: 0.62, + description: 'Running the advisor selector widget tests before commit.', + model: 'GPT-5.6 Sol', + currentTool: 'terminal.run', + ), + ], + fileWorkspace: FileWorkspaceState( + path: 'lib/src/quick_open.dart', + entries: const [ + DeveloperFileEntry(path: 'lib', kind: 'dir'), + DeveloperFileEntry(path: 'test', kind: 'dir'), + DeveloperFileEntry(path: 'README.md', kind: 'file', size: 412), + DeveloperFileEntry(path: 'CHANGELOG.md', kind: 'file', size: 268), + DeveloperFileEntry(path: 'pubspec.yaml', kind: 'file', size: 301), + DeveloperFileEntry(path: 'analysis_options.yaml', kind: 'file', size: 88), + ], + content: _demoWorkspaceFiles['lib/src/quick_open.dart'], + diff: _demoQuickOpenDiff, + revision: 'demo-revision-3', + ), + reviews: const [ + ReviewWorkspaceItem( + reviewId: 'demo-review-1', + sessionId: 'sess-settings', + status: 'completed', + path: 'lib/src/quick_open.dart', + findings: >[ + { + 'path': 'lib/src/quick_open.dart', + 'severity': 'info', + 'message': + 'Result cache is now keyed by (sessionId, query); no cross-' + 'session reuse remains.', + }, + ], + ), + ], + composer: const SessionComposerState( + modelLabel: 'GPT-5.6 Sol', + modelSelector: 'openai-codex/gpt-5.6-sol', + modelChoices: [ + ComposerModelChoice( + label: 'GPT-5.6 Sol', + selector: 'openai-codex/gpt-5.6-sol', + provider: 'openai-codex', + providerLabel: 'OpenAI Codex', + ), + ], + thinking: 'high', + thinkingLevels: ['off', 'medium', 'high'], + fastAvailable: true, + ), + themePreference: T4ThemePreference.system, +); + +/// Small static workspace used by quick open, file search, and file reads. +const Map _demoWorkspaceFiles = { + 'README.md': + '# demo workspace\n' + '\n' + 'Sample project served by the T4 Code public preview. Everything here ' + 'is static display data; no command leaves the browser.\n' + '\n' + '- `lib/main.dart` — app entrypoint\n' + '- `lib/src/quick_open.dart` — palette + session-scoped result cache\n' + '- `test/quick_open_test.dart` — regression coverage for the cache\n', + 'CHANGELOG.md': + '## Unreleased\n' + '\n' + '- Quick open: scope cached results per session.\n' + '- Usage pane: show provider limit windows with reset times.\n', + 'pubspec.yaml': + 'name: demo_workspace\n' + 'description: Sample workspace for the T4 Code public preview.\n' + 'environment:\n' + " sdk: '>=3.8.0 <4.0.0'\n" + 'dependencies:\n' + ' flutter:\n' + ' sdk: flutter\n', + 'analysis_options.yaml': + 'include: package:flutter_lints/flutter.yaml\n' + 'linter:\n' + ' rules:\n' + ' - prefer_const_constructors\n', + 'lib/main.dart': + "import 'package:flutter/widgets.dart';\n" + "import 'src/quick_open.dart';\n" + '\n' + 'void main() => runApp(const DemoWorkspaceApp());\n', + 'lib/src/quick_open.dart': + 'final Map<(String, String), List> _resultCache =\n' + ' <(String, String), List>{};\n' + '\n' + 'Future> search(String sessionId, String query) async {\n' + ' final key = (sessionId, query);\n' + ' return _resultCache[key] ??= await _lookup(query);\n' + '}\n', + 'lib/src/session_scope.dart': + '/// Identifies the session a palette query belongs to.\n' + 'final class SessionScope {\n' + ' const SessionScope(this.sessionId);\n' + ' final String sessionId;\n' + '}\n', + 'test/quick_open_test.dart': + "import 'package:flutter_test/flutter_test.dart';\n" + '\n' + 'void main() {\n' + " test('quick open scopes cached results per session', () {\n" + ' // Regression: switching sessions must not replay stale paths.\n' + ' });\n' + '}\n', +}; + +const String _demoQuickOpenDiff = + '--- a/lib/src/quick_open.dart\n' + '+++ b/lib/src/quick_open.dart\n' + '@@ -15,10 +15,11 @@\n' + '-final Map> _resultCache =\n' + '- >{};\n' + '+final Map<(String, String), List> _resultCache =\n' + '+ <(String, String), List>{};\n' + ' \n' + '-Future> search(String query) async {\n' + '- final cached = _resultCache[query];\n' + '+Future> search(String sessionId, String query) async {\n' + '+ final key = (sessionId, query);\n' + '+ final cached = _resultCache[key];\n' + ' if (cached != null) return cached;\n'; + +/// Safe action sink for the public preview. Interactive controls render as the +/// real client does, but no command leaves the browser. +final class _DemoActions implements T4Actions { + const _DemoActions(); + + @override + Future refreshSettings() async {} + + @override + Future setThemePreference(T4ThemePreference preference) async {} + + @override + Future selectSession(String sessionId) async {} + + @override + Future submitPrompt( + String message, { + List images = const [], + }) async => false; + + @override + Future queuePrompt(String message) async => false; + + @override + Future respondToAttention( + AttentionItem item, + AttentionResponse response, + ) async => false; + + @override + Future readTranscriptImage( + String entryId, + TranscriptImageMetadata image, + ) async => Uint8List(0); + + @override + Future readUsage() async => const UsageReadResult( + generatedAt: _demoGeneratedAtMs, + reports: [ + UsageReport( + provider: 'openai-codex', + fetchedAt: _demoGeneratedAtMs, + limits: [ + UsageLimit( + id: 'session-5h', + label: '5-hour window', + scope: UsageScope(provider: 'openai-codex'), + window: UsageWindow( + id: 'session-5h', + label: '5h', + durationMs: 5 * 60 * 60 * 1000, + resetsAt: _demoGeneratedAtMs + 2 * 60 * 60 * 1000, + ), + amount: UsageAmount( + unit: UsageUnit.percent, + used: 34, + limit: 100, + remaining: 66, + usedFraction: 0.34, + remainingFraction: 0.66, + ), + status: UsageStatus.ok, + notes: [], + ), + UsageLimit( + id: 'weekly', + label: 'Weekly window', + scope: UsageScope(provider: 'openai-codex'), + window: UsageWindow( + id: 'weekly', + label: '7d', + durationMs: 7 * 24 * 60 * 60 * 1000, + resetsAt: _demoGeneratedAtMs + 3 * 24 * 60 * 60 * 1000, + ), + amount: UsageAmount( + unit: UsageUnit.percent, + used: 61, + limit: 100, + remaining: 39, + usedFraction: 0.61, + remainingFraction: 0.39, + ), + status: UsageStatus.warning, + notes: [], + ), + ], + notes: [], + metadata: {}, + ), + ], + accountsWithoutUsage: [], + capacity: >{}, + ); + + @override + Future readBrokerStatus() async => + const BrokerStatusResult( + state: BrokerState.connected, + generation: 7, + endpoint: 'https://demo.t4code.ts.net', + ); + + @override + Future searchProjectFiles( + String query, { + int limit = 12, + }) async { + final needle = query.trim().toLowerCase(); + final matches = _demoWorkspaceFiles.keys + .where((path) => needle.isEmpty || path.toLowerCase().contains(needle)) + .take(limit) + .toList(growable: false); + return ProjectFileSearchResult(paths: matches, truncated: false); + } + + @override + dynamic noSuchMethod(Invocation invocation) { + return switch (invocation.memberName) { + #searchTranscripts => Future.value( + const TranscriptSearchResult( + items: [], + incomplete: false, + index: TranscriptSearchIndexStatus( + state: TranscriptSearchIndexState.ready, + indexedSessions: 5, + knownSessions: 5, + generation: 'demo', + ), + ), + ), + #loadTranscriptContext => Future.value( + const TranscriptContextResult( + anchorId: '', + rows: [], + anchorIndex: 0, + hasBefore: false, + hasAfter: false, + generation: 'demo', + ), + ), + #submitPrompt || + #queuePrompt || + #respondToAttention => Future.value(false), + #openTerminal || #launchPreview => Future.value(''), + _ => Future.value(), + }; + } +} diff --git a/apps/flutter/lib/src/ui/access_mode_selector.dart b/apps/flutter/lib/src/ui/access_mode_selector.dart new file mode 100644 index 00000000..2d3dd0d9 --- /dev/null +++ b/apps/flutter/lib/src/ui/access_mode_selector.dart @@ -0,0 +1,169 @@ +part of 't4_app.dart'; + +/// One selectable entry in an [AccessModeSelector] menu. +class AccessModeOption { + const AccessModeOption({ + required this.id, + required this.label, + this.detail, + this.selected = false, + }); + + /// Stable identifier passed to [AccessModeSelector.onSelected]. + final String id; + + /// Primary menu-row text. + final String label; + + /// Optional dimmed second line describing the option. + final String? detail; + + /// Whether this option renders with a leading check mark. + final bool selected; +} + +/// Quiet access-mode pill opening a themed [MenuAnchor] of options. +/// +/// Matches the composer pill look: hairline stadium border, 12 px label, +/// chevron, and a 14 px shield leading icon. With an empty [options] list the +/// pill is informational: the menu shows a single disabled item repeating +/// [label] instead of selectable entries. +class AccessModeSelector extends StatelessWidget { + const AccessModeSelector({ + required this.label, + required this.options, + required this.onSelected, + this.enabled = true, + this.readOnly = false, + super.key, + }); + + /// Text shown inside the pill (current mode). + final String label; + + /// Selectable modes; empty means display-only. + final List options; + + /// Called with the tapped option's [AccessModeOption.id]. + final ValueChanged onSelected; + + /// Disables the pill entirely when false. + final bool enabled; + + /// Renders options as a non-interactive granted-permissions status list: + /// rows are disabled and the check mark means "granted", not "chosen". + /// Use this until the wire protocol exposes a real access-mode command. + final bool readOnly; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + return Semantics( + label: 'Access mode: $label', + button: true, + enabled: enabled, + child: MenuAnchor( + style: const MenuStyle( + visualDensity: VisualDensity.compact, + maximumSize: WidgetStatePropertyAll(Size(320, 360)), + ), + alignmentOffset: const Offset(0, _T4Space.xs), + menuChildren: options.isEmpty + ? [ + MenuItemButton( + style: _accessMenuItemStyle, + onPressed: null, + child: Text(label), + ), + ] + : [ + for (final option in options) + MenuItemButton( + key: ValueKey('access-mode-${option.id}'), + style: _accessMenuItemStyle, + leadingIcon: option.selected + ? Icon(Icons.check, size: 16, color: scheme.primary) + : const SizedBox(width: 16), + onPressed: readOnly ? null : () => onSelected(option.id), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(option.label), + if (option.detail case final detail?) + Text( + detail, + style: TextStyle( + fontSize: 11, + color: scheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + builder: (context, controller, child) => _AccessModePill( + label: label, + onTap: enabled + ? () => controller.isOpen ? controller.close() : controller.open() + : null, + ), + ), + ); + } +} + +final ButtonStyle _accessMenuItemStyle = MenuItemButton.styleFrom( + visualDensity: VisualDensity.compact, + textStyle: const TextStyle(fontSize: 12), + minimumSize: const Size.fromHeight(36), +); + +/// Hairline stadium pill anchor for [AccessModeSelector]. +final class _AccessModePill extends StatelessWidget { + const _AccessModePill({required this.label, required this.onTap}); + + final String label; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final enabled = onTap != null; + final color = enabled ? scheme.onSurfaceVariant : scheme.outline; + final shape = StadiumBorder(side: BorderSide(color: scheme.outlineVariant)); + return Material( + color: Colors.transparent, + shape: shape, + child: InkWell( + customBorder: const StadiumBorder(), + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: _T4Space.xs, + vertical: _T4Space.xxs + 1, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.shield_outlined, size: 14, color: color), + const SizedBox(width: _T4Space.xxs), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 160), + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 12, color: color), + ), + ), + const SizedBox(width: 2), + Icon(Icons.keyboard_arrow_down, size: 14, color: color), + ], + ), + ), + ), + ); + } +} diff --git a/apps/flutter/lib/src/ui/adaptive_session_shell.dart b/apps/flutter/lib/src/ui/adaptive_session_shell.dart index 2241e076..e14fe1b0 100644 --- a/apps/flutter/lib/src/ui/adaptive_session_shell.dart +++ b/apps/flutter/lib/src/ui/adaptive_session_shell.dart @@ -28,6 +28,7 @@ final class _AdaptiveSessionShellState extends State<_AdaptiveSessionShell> { bool _showSettings = false; bool _showSearch = false; bool _showUsage = false; + bool _showContextPanel = false; int _developerInitialTab = 0; Future _connect() async { @@ -208,6 +209,298 @@ final class _AdaptiveSessionShellState extends State<_AdaptiveSessionShell> { void _closeUsage() => setState(() => _showUsage = false); + void _toggleContextPanel() => + setState(() => _showContextPanel = !_showContextPanel); + + void _toggleSearch() { + if (_showSearch) { + _closeSearch(); + } else { + _openSearch(closeDrawer: false); + } + } + + void _toggleSettings() { + if (_showSettings) { + _closeSettings(); + } else { + _openSettings(closeDrawer: false); + } + } + + void _toggleDeveloper() { + if (_showDeveloper) { + _closeDeveloper(); + } else { + _openDeveloper(); + } + } + + /// Escape: returns to the conversation when any takeover surface is open. + void _dismissTakeovers() { + if (!_showHostManager && + !_showAttention && + !_showDeveloper && + !_showSettings && + !_showSearch && + !_showUsage) { + return; + } + setState(() { + _showHostManager = false; + _showAttention = false; + _showDeveloper = false; + _showSettings = false; + _showSearch = false; + _showUsage = false; + }); + } + + /// Selects the Nth session of the list the rail renders by default + /// (non-archived, unfiltered), using the same select action as the rail. + void _selectSessionAt(int index) { + final visible = widget.state.sessions + .where((session) => !session.archived) + .toList(growable: false); + if (index < 0 || index >= visible.length) return; + unawaited(_selectSession(visible[index].sessionId, closeDrawer: false)); + } + + /// Same create flow (gate + dialog) as the session rail's new-session + /// button, reachable from the keyboard. + Future _createSessionFromShortcut() async { + final canCreate = + widget.state.connectionPhase == ConnectionPhase.ready && + widget.state.grantedCapabilities.contains('sessions.manage') && + !widget.state.sessionOperationPending && + widget.state.sessions.isNotEmpty; + if (!canCreate) return; + final projects = {}; + for (final session in widget.state.sessions) { + projects.putIfAbsent(session.projectId, () => session.projectName); + } + if (projects.isEmpty) return; + await showDialog( + context: context, + builder: (context) => + _CreateSessionDialog(actions: widget.actions, projects: projects), + ); + } + + Map _shortcutBindings() { + final useMeta = + defaultTargetPlatform == TargetPlatform.macOS || + defaultTargetPlatform == TargetPlatform.iOS; + SingleActivator mod(LogicalKeyboardKey key, {bool shift = false}) => + SingleActivator(key, meta: useMeta, control: !useMeta, shift: shift); + + const digits = [ + LogicalKeyboardKey.digit1, + LogicalKeyboardKey.digit2, + LogicalKeyboardKey.digit3, + LogicalKeyboardKey.digit4, + LogicalKeyboardKey.digit5, + LogicalKeyboardKey.digit6, + LogicalKeyboardKey.digit7, + LogicalKeyboardKey.digit8, + LogicalKeyboardKey.digit9, + ]; + + return { + mod(LogicalKeyboardKey.keyK): () => unawaited(_openQuickOpen()), + mod(LogicalKeyboardKey.keyN): () => + unawaited(_createSessionFromShortcut()), + mod(LogicalKeyboardKey.keyF, shift: true): _toggleSearch, + mod(LogicalKeyboardKey.comma): _toggleSettings, + mod(LogicalKeyboardKey.keyJ): _toggleDeveloper, + mod(LogicalKeyboardKey.keyI): _toggleContextPanel, + mod(LogicalKeyboardKey.keyP, shift: true): () => + unawaited(_openCommandPalette()), + for (var i = 0; i < digits.length; i++) + mod(digits[i]): () => _selectSessionAt(i), + const SingleActivator(LogicalKeyboardKey.escape): _dismissTakeovers, + }; + } + + /// mod+shift+P: command palette over the shell's global actions. The + /// shortcut labels mirror the platform modifier used by [_shortcutBindings]. + Future _openCommandPalette() async { + final useMeta = + defaultTargetPlatform == TargetPlatform.macOS || + defaultTargetPlatform == TargetPlatform.iOS; + final mod = useMeta ? '\u2318' : 'Ctrl+'; + await showCommandPalette( + context, + commands: [ + PaletteCommand( + id: 'quick-open', + title: 'Quick open project file', + shortcutLabel: '${mod}K', + enabled: _canQuickOpen, + run: () => unawaited(_openQuickOpen()), + ), + PaletteCommand( + id: 'new-session', + title: 'New session', + shortcutLabel: '${mod}N', + enabled: + widget.state.connectionPhase == ConnectionPhase.ready && + widget.state.grantedCapabilities.contains('sessions.manage'), + run: () => unawaited(_createSessionFromShortcut()), + ), + PaletteCommand( + id: 'search-transcripts', + title: 'Search transcripts', + shortcutLabel: '$mod\u21e7F', + run: _toggleSearch, + ), + PaletteCommand( + id: 'developer-tools', + title: 'Toggle developer tools', + shortcutLabel: '${mod}J', + run: _toggleDeveloper, + ), + PaletteCommand( + id: 'context-panel', + title: 'Toggle context panel', + shortcutLabel: '${mod}I', + run: _toggleContextPanel, + ), + PaletteCommand( + id: 'usage', + title: 'Usage and accounts', + run: () => _openUsage(closeDrawer: false), + ), + PaletteCommand( + id: 'settings', + title: 'Settings', + shortcutLabel: '$mod,', + run: _toggleSettings, + ), + PaletteCommand( + id: 'manage-hosts', + title: 'Manage hosts', + run: () => _openHostManager(closeDrawer: false), + ), + ], + ); + } + + Widget _contextRow(BuildContext context, String label, String value) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.symmetric(vertical: _T4Space.xxs), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 88, + child: Text( + label, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + Expanded( + child: Text( + value, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall, + ), + ), + ], + ), + ); + } + + /// Section list for the right context panel. Wave 4 extends this with + /// richer sections; keep it as the single mount point. + List _buildContextSections(BuildContext context) { + final session = widget.state.selectedSession; + final profile = widget.state.hostDirectory.activeProfile; + final modelLabel = widget.state.composer.modelLabel; + final capabilities = widget.state.grantedCapabilities; + final ready = + widget.state.connectionPhase == ConnectionPhase.ready && + session != null; + return [ + ContextPanelSection( + id: 'session', + title: 'Session', + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _contextRow(context, 'Title', _displaySessionTitle(session)), + if (session != null) + _contextRow(context, 'Project', session.projectName), + if (session != null && session.status.trim().isNotEmpty) + _contextRow(context, 'Status', session.status), + _contextRow( + context, + 'Connection', + widget.state.connectionPhase.label, + ), + if (profile != null) _contextRow(context, 'Host', profile.label), + if (modelLabel != null) _contextRow(context, 'Model', modelLabel), + ], + ), + ), + if (ready && capabilities.contains('files.diff')) + ContextPanelSection( + id: 'review', + title: 'Review', + child: SizedBox( + height: 380, + child: ReviewPanelBody( + state: widget.state, + actions: widget.actions, + ), + ), + ), + if (ready && + capabilities.contains('files.list') && + capabilities.contains('files.read')) + ContextPanelSection( + id: 'files', + title: 'Files', + initiallyExpanded: false, + child: SizedBox( + height: 380, + child: FilesPanelBody(state: widget.state, actions: widget.actions), + ), + ), + if (ready && capabilities.contains('audit.read')) + ContextPanelSection( + id: 'activity', + title: 'Activity', + initiallyExpanded: false, + child: SizedBox( + height: 320, + child: ActivityPanelBody( + state: widget.state, + actions: widget.actions, + ), + ), + ), + ]; + } + + Widget _contextPanelToggle(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return IconButton( + onPressed: _toggleContextPanel, + tooltip: 'Toggle context panel', + iconSize: _T4Size.indicator, + padding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + constraints: const BoxConstraints.tightFor(width: 28, height: 28), + color: scheme.onSurfaceVariant, + icon: const Icon(Icons.view_sidebar_outlined), + ); + } + Widget _surfaceNavigationEntries({ required bool closeDrawer, required bool rail, @@ -347,6 +640,8 @@ final class _AdaptiveSessionShellState extends State<_AdaptiveSessionShell> { onOpenAttention: _openAttention, onOpenDeveloper: _openDeveloper, onOpenQuickOpen: _openQuickOpen, + onSelectSession: (sessionId) => + _selectSession(sessionId, closeDrawer: false), ); } @@ -360,13 +655,19 @@ final class _AdaptiveSessionShellState extends State<_AdaptiveSessionShell> { return _HostOnboardingPage(state: widget.state, actions: widget.actions); } - return LayoutBuilder( - builder: (context, constraints) { - if (constraints.maxWidth >= _T4Breakpoints.wide) { - return _buildWide(context); - } - return _buildCompact(context); - }, + return CallbackShortcuts( + bindings: _shortcutBindings(), + child: Focus( + autofocus: true, + child: LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxWidth >= _T4Breakpoints.wide) { + return _buildWide(context); + } + return _buildCompact(context); + }, + ), + ), ); } @@ -400,7 +701,23 @@ final class _AdaptiveSessionShellState extends State<_AdaptiveSessionShell> { ), ), const VerticalDivider(width: _T4Size.divider), - Expanded(child: _primaryContent(showHeader: true)), + Expanded( + child: Stack( + children: [ + Positioned.fill(child: _primaryContent(showHeader: true)), + Positioned( + top: _T4Space.sm, + right: _T4Space.sm, + child: _contextPanelToggle(context), + ), + ], + ), + ), + if (_showContextPanel) + ContextPanel( + sections: _buildContextSections(context), + onClose: _toggleContextPanel, + ), ], ), ), @@ -513,6 +830,13 @@ final class _AdaptiveSessionShellState extends State<_AdaptiveSessionShell> { : Icons.power_settings_new, ), ), + if (!_showHostManager && !_showSearch && !_showUsage) + IconButton( + onPressed: () => + _scaffoldKey.currentState?.openEndDrawer(), + tooltip: 'Toggle context panel', + icon: const Icon(Icons.view_sidebar_outlined), + ), ], ], ), @@ -542,6 +866,14 @@ final class _AdaptiveSessionShellState extends State<_AdaptiveSessionShell> { ], ), ), + endDrawer: Drawer( + child: SafeArea( + child: ContextPanel( + sections: _buildContextSections(context), + onClose: () => _scaffoldKey.currentState?.closeEndDrawer(), + ), + ), + ), body: _primaryContent(showHeader: false), ); } @@ -583,11 +915,15 @@ final class _CompactConnectionLabel extends StatelessWidget { ), ), const SizedBox(width: _T4Space.xs), - Text( - phase.label, - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: scheme.onSurfaceVariant), + Flexible( + child: Text( + phase.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: scheme.onSurfaceVariant), + ), ), ], ), diff --git a/apps/flutter/lib/src/ui/attention_pane.dart b/apps/flutter/lib/src/ui/attention_pane.dart index dfef627f..7af82245 100644 --- a/apps/flutter/lib/src/ui/attention_pane.dart +++ b/apps/flutter/lib/src/ui/attention_pane.dart @@ -1,6 +1,6 @@ part of 't4_app.dart'; -final class _AttentionPane extends StatefulWidget { +final class _AttentionPane extends StatelessWidget { const _AttentionPane({ required this.state, required this.actions, @@ -14,10 +14,37 @@ final class _AttentionPane extends StatefulWidget { final Future Function(String sessionId) onOpenSession; @override - State<_AttentionPane> createState() => _AttentionPaneState(); + Widget build(BuildContext context) => InboxFlyoutContent( + state: state, + actions: actions, + onDone: onDone, + onOpenSession: onOpenSession, + ); +} + +/// The inbox content column (Needs you / Updates / Agents), embeddable in a +/// ~400 px anchored popover or, via [_AttentionPane], the full-width takeover. +final class InboxFlyoutContent extends StatefulWidget { + const InboxFlyoutContent({ + required this.state, + required this.actions, + required this.onDone, + required this.onOpenSession, + this.showTitle = true, + super.key, + }); + + final T4ViewState state; + final T4Actions actions; + final VoidCallback onDone; + final Future Function(String sessionId) onOpenSession; + final bool showTitle; + + @override + State createState() => _InboxFlyoutContentState(); } -final class _AttentionPaneState extends State<_AttentionPane> +final class _InboxFlyoutContentState extends State with SingleTickerProviderStateMixin { late final TabController _tabs; final Set _responding = {}; @@ -96,10 +123,11 @@ final class _AttentionPaneState extends State<_AttentionPane> .toList(growable: false); return Column( children: [ - _AttentionHeader( - urgentCount: widget.state.urgentAttentionCount, - onDone: widget.onDone, - ), + if (widget.showTitle) + _AttentionHeader( + urgentCount: widget.state.urgentAttentionCount, + onDone: widget.onDone, + ), if (widget.state.attentionPartial) MaterialBanner( content: Text( @@ -248,6 +276,117 @@ final class _AttentionHeader extends StatelessWidget { ); } +IconData _attentionKindIcon(AttentionKind kind) => switch (kind) { + AttentionKind.approval => Icons.shield_outlined, + AttentionKind.question => Icons.help_outline, + AttentionKind.plan => Icons.account_tree_outlined, + AttentionKind.confirmation => Icons.verified_user_outlined, + AttentionKind.completed => Icons.check_circle_outline, + AttentionKind.failed => Icons.error_outline, + AttentionKind.cancelled => Icons.cancel_outlined, +}; + +Color _attentionKindColor(AttentionKind kind, ColorScheme scheme) => + switch (kind) { + AttentionKind.failed => scheme.error, + AttentionKind.cancelled => scheme.onSurfaceVariant, + AttentionKind.completed => scheme.primary, + _ => scheme.tertiary, + }; + +/// Question choice buttons shared by [_AttentionCard] and +/// [InlineApprovalCard]: one outlined button per choice plus the optional +/// free-text answer affordance. +final class _AttentionQuestionActions extends StatelessWidget { + const _AttentionQuestionActions({ + required this.item, + required this.enabled, + required this.onRespond, + required this.onCustomAnswer, + }); + + final AttentionItem item; + final bool enabled; + final ValueChanged onRespond; + final VoidCallback onCustomAnswer; + + @override + Widget build(BuildContext context) => Wrap( + spacing: _T4Space.sm, + runSpacing: _T4Space.sm, + children: [ + for (final choice in item.choices) + OutlinedButton( + onPressed: !enabled + ? null + : () => onRespond( + AttentionResponse( + decision: AttentionDecision.approve, + optionIds: [choice.id], + ), + ), + child: Text(choice.label), + ), + if (item.allowText) + FilledButton.tonal( + onPressed: !enabled ? null : onCustomAnswer, + child: const Text('Type an answer'), + ), + ], + ); +} + +/// Approve / deny / reject / revise buttons shared by [_AttentionCard] and +/// [InlineApprovalCard]; label and decision semantics depend on the item kind. +final class _AttentionDecisionActions extends StatelessWidget { + const _AttentionDecisionActions({ + required this.item, + required this.enabled, + required this.onRespond, + required this.onRevise, + }); + + final AttentionItem item; + final bool enabled; + final ValueChanged onRespond; + final VoidCallback onRevise; + + @override + Widget build(BuildContext context) => Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (item.kind == AttentionKind.plan) ...[ + TextButton( + onPressed: !enabled ? null : onRevise, + child: const Text('Revise'), + ), + const SizedBox(width: _T4Space.sm), + ], + OutlinedButton( + onPressed: !enabled + ? null + : () => onRespond( + AttentionResponse( + decision: item.kind == AttentionKind.plan + ? AttentionDecision.reject + : AttentionDecision.deny, + ), + ), + child: Text(item.kind == AttentionKind.plan ? 'Reject' : 'Deny'), + ), + const SizedBox(width: _T4Space.sm), + FilledButton( + onPressed: !enabled + ? null + : () => onRespond( + const AttentionResponse(decision: AttentionDecision.approve), + ), + child: const Text('Approve'), + ), + ], + ); +} + final class _AttentionCard extends StatelessWidget { const _AttentionCard({ required this.item, @@ -281,7 +420,11 @@ final class _AttentionCard extends StatelessWidget { children: [ Row( children: [ - Icon(_icon, size: 20, color: _color(scheme)), + Icon( + _attentionKindIcon(item.kind), + size: 20, + color: _attentionKindColor(item.kind, scheme), + ), const SizedBox(width: _T4Space.sm), Expanded( child: Text( @@ -307,9 +450,19 @@ final class _AttentionCard extends StatelessWidget { if (item.needsResponse) ...[ const SizedBox(height: _T4Space.sm), if (item.kind == AttentionKind.question) - _questionActions() + _AttentionQuestionActions( + item: item, + enabled: canRespond && !busy, + onRespond: onRespond, + onCustomAnswer: onCustomAnswer, + ) else - _decisionActions(), + _AttentionDecisionActions( + item: item, + enabled: canRespond && !busy, + onRespond: onRespond, + onRevise: onRevise, + ), if (!canRespond) Padding( padding: const EdgeInsets.only(top: _T4Space.sm), @@ -333,81 +486,116 @@ final class _AttentionCard extends StatelessWidget { ), ); } +} - Widget _questionActions() => Wrap( - spacing: _T4Space.sm, - runSpacing: _T4Space.sm, - children: [ - for (final choice in item.choices) - OutlinedButton( - onPressed: !canRespond || busy - ? null - : () => onRespond( - AttentionResponse( - decision: AttentionDecision.approve, - optionIds: [choice.id], - ), - ), - child: Text(choice.label), - ), - if (item.allowText) - FilledButton.tonal( - onPressed: !canRespond || busy ? null : onCustomAnswer, - child: const Text('Type an answer'), - ), - ], - ); +/// A single actionable attention item (approval or question) rendered inline +/// in the conversation transcript. Shares choice/decision button semantics +/// with the inbox cards via [_AttentionQuestionActions] and +/// [_AttentionDecisionActions]. +final class InlineApprovalCard extends StatelessWidget { + const InlineApprovalCard({ + required this.item, + required this.onRespond, + required this.onRevise, + required this.onCustomAnswer, + required this.onOpenInbox, + this.busy = false, + this.canRespond = true, + super.key, + }); - Widget _decisionActions() => Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - if (item.kind == AttentionKind.plan) ...[ - TextButton( - onPressed: !canRespond || busy ? null : onRevise, - child: const Text('Revise'), - ), - const SizedBox(width: _T4Space.sm), - ], - OutlinedButton( - onPressed: !canRespond || busy - ? null - : () => onRespond( - AttentionResponse( - decision: item.kind == AttentionKind.plan - ? AttentionDecision.reject - : AttentionDecision.deny, - ), + final AttentionItem item; + final ValueChanged onRespond; + final VoidCallback onRevise; + final VoidCallback onCustomAnswer; + final VoidCallback onOpenInbox; + final bool busy; + final bool canRespond; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final enabled = canRespond && !busy && item.actionable; + return Align( + alignment: Alignment.centerLeft, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 560), + child: Container( + padding: const EdgeInsets.all(_T4Space.md), + decoration: BoxDecoration( + color: scheme.surface, + borderRadius: BorderRadius.circular(_T4Radius.md), + border: Border.all( + width: 1, + color: scheme.tertiary.withValues(alpha: 0.4), + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + _attentionKindIcon(item.kind), + size: 18, + color: _attentionKindColor(item.kind, scheme), + ), + const SizedBox(width: _T4Space.sm), + Expanded( + child: Text( + item.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleSmall, + ), + ), + if (busy) + const SizedBox.square( + dimension: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + else + IconButton( + onPressed: onOpenInbox, + tooltip: 'Open inbox', + iconSize: 18, + visualDensity: VisualDensity.compact, + icon: const Icon(Icons.inbox_outlined), + ), + ], ), - child: Text(item.kind == AttentionKind.plan ? 'Reject' : 'Deny'), - ), - const SizedBox(width: _T4Space.sm), - FilledButton( - onPressed: !canRespond || busy - ? null - : () => onRespond( - const AttentionResponse(decision: AttentionDecision.approve), + const SizedBox(height: _T4Space.xs), + Text( + item.summary, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall, ), - child: const Text('Approve'), + if (item.needsResponse) ...[ + const SizedBox(height: _T4Space.sm), + if (item.kind == AttentionKind.question) + _AttentionQuestionActions( + item: item, + enabled: enabled, + onRespond: onRespond, + onCustomAnswer: onCustomAnswer, + ) + else + _AttentionDecisionActions( + item: item, + enabled: enabled, + onRespond: onRespond, + onRevise: onRevise, + ), + ], + ], + ), + ), ), - ], - ); - - IconData get _icon => switch (item.kind) { - AttentionKind.approval => Icons.shield_outlined, - AttentionKind.question => Icons.help_outline, - AttentionKind.plan => Icons.account_tree_outlined, - AttentionKind.confirmation => Icons.verified_user_outlined, - AttentionKind.completed => Icons.check_circle_outline, - AttentionKind.failed => Icons.error_outline, - AttentionKind.cancelled => Icons.cancel_outlined, - }; - - Color _color(ColorScheme scheme) => switch (item.kind) { - AttentionKind.failed => scheme.error, - AttentionKind.cancelled => scheme.onSurfaceVariant, - AttentionKind.completed => scheme.primary, - _ => scheme.tertiary, - }; + ); + } } final class _AgentActivityList extends StatefulWidget { diff --git a/apps/flutter/lib/src/ui/command_palette.dart b/apps/flutter/lib/src/ui/command_palette.dart new file mode 100644 index 00000000..d138a489 --- /dev/null +++ b/apps/flutter/lib/src/ui/command_palette.dart @@ -0,0 +1,341 @@ +part of 't4_app.dart'; + +/// One executable entry in the command palette. +class PaletteCommand { + const PaletteCommand({ + required this.id, + required this.title, + this.shortcutLabel, + this.enabled = true, + required this.run, + }); + + /// Stable identifier (used for widget keys and de-duplication by callers). + final String id; + + /// Human-readable command title; the fuzzy filter matches against this. + final String title; + + /// Optional right-aligned shortcut hint (e.g. `⌘K`). + final String? shortcutLabel; + + /// Disabled commands render dimmed and cannot be selected or run. + final bool enabled; + + /// Invoked after the palette dialog has been popped. + final VoidCallback run; +} + +/// Shows the centered command palette dialog over [context]. +/// +/// Fully keyboard drivable: the filter field autofocuses, ArrowUp/Down move +/// the active row, Enter pops the dialog and runs the active command, Escape +/// closes. Clicking a row does the same as Enter for that row. +Future showCommandPalette( + BuildContext context, { + required List commands, +}) { + return showDialog( + context: context, + barrierDismissible: true, + builder: (context) => _CommandPaletteDialog(commands: commands), + ); +} + +/// A [PaletteCommand] paired with the title indices its match highlighted. +final class _PaletteMatch { + const _PaletteMatch({required this.command, required this.highlight}); + + final PaletteCommand command; + final Set highlight; +} + +/// Case-insensitive subsequence match of [query] inside [title]. +/// +/// Characters at word starts are preferred so multi-word queries behave like +/// word-prefix matching (`"op set"` matches **Op**en **Set**tings). Returns +/// the matched character indices for highlighting, or null when [query] is +/// not a subsequence of [title]. +Set? _fuzzyMatch(String query, String title) { + final needle = query.toLowerCase(); + final haystack = title.toLowerCase(); + if (needle.isEmpty) return const {}; + final matched = {}; + var from = 0; + for (var i = 0; i < needle.length; i++) { + final ch = needle[i]; + if (ch == ' ') continue; + var at = -1; + // Prefer a word-start occurrence at/after the cursor, else any occurrence. + for (var j = from; j < haystack.length; j++) { + if (haystack[j] != ch) continue; + final wordStart = j == 0 || haystack[j - 1] == ' '; + if (wordStart) { + at = j; + break; + } + if (at < 0) at = j; + } + if (at < 0) return null; + matched.add(at); + from = at + 1; + } + return matched; +} + +final class _CommandPaletteDialog extends StatefulWidget { + const _CommandPaletteDialog({required this.commands}); + + final List commands; + + @override + State<_CommandPaletteDialog> createState() => _CommandPaletteDialogState(); +} + +final class _CommandPaletteDialogState extends State<_CommandPaletteDialog> { + final TextEditingController _queryController = TextEditingController(); + final FocusNode _fieldFocus = FocusNode(debugLabel: 'Command palette query'); + final ScrollController _scrollController = ScrollController(); + List<_PaletteMatch> _matches = const <_PaletteMatch>[]; + int _active = -1; + + @override + void initState() { + super.initState(); + _refilter(''); + } + + @override + void dispose() { + _queryController.dispose(); + _fieldFocus.dispose(); + _scrollController.dispose(); + super.dispose(); + } + + void _refilter(String query) { + final matches = <_PaletteMatch>[]; + for (final command in widget.commands) { + final highlight = _fuzzyMatch(query.trim(), command.title); + if (highlight == null) continue; + matches.add(_PaletteMatch(command: command, highlight: highlight)); + } + setState(() { + _matches = matches; + _active = matches.indexWhere((match) => match.command.enabled); + }); + } + + /// Moves the active row by [delta], skipping disabled commands. + void _moveActive(int delta) { + if (_matches.isEmpty) return; + var index = _active; + for (var step = 0; step < _matches.length; step++) { + index = (index + delta) % _matches.length; + if (index < 0) index += _matches.length; + if (_matches[index].command.enabled) { + setState(() => _active = index); + _revealActive(); + return; + } + } + } + + void _revealActive() { + if (_active < 0 || !_scrollController.hasClients) return; + const rowExtent = 40.0; + final viewport = _scrollController.position.viewportDimension; + final top = _active * rowExtent; + final offset = _scrollController.offset; + if (top < offset) { + _scrollController.jumpTo(top); + } else if (top + rowExtent > offset + viewport) { + _scrollController.jumpTo(top + rowExtent - viewport); + } + } + + void _runCommand(PaletteCommand command) { + if (!command.enabled) return; + Navigator.of(context).pop(); + command.run(); + } + + KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) { + if (event is KeyUpEvent) return KeyEventResult.ignored; + final key = event.logicalKey; + if (key == LogicalKeyboardKey.arrowDown) { + _moveActive(1); + return KeyEventResult.handled; + } + if (key == LogicalKeyboardKey.arrowUp) { + _moveActive(-1); + return KeyEventResult.handled; + } + if (key == LogicalKeyboardKey.enter || + key == LogicalKeyboardKey.numpadEnter) { + if (_active >= 0 && _active < _matches.length) { + _runCommand(_matches[_active].command); + } + return KeyEventResult.handled; + } + if (key == LogicalKeyboardKey.escape) { + Navigator.of(context).pop(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final topInset = MediaQuery.sizeOf(context).height * 0.15; + return SafeArea( + child: Align( + alignment: Alignment.topCenter, + child: Padding( + padding: EdgeInsets.only(top: topInset), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 560, maxHeight: 420), + child: Material( + color: scheme.surface, + elevation: 8, + borderRadius: BorderRadius.circular(_T4Radius.md), + clipBehavior: Clip.antiAlias, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Focus( + onKeyEvent: _onKeyEvent, + child: TextField( + controller: _queryController, + focusNode: _fieldFocus, + autofocus: true, + onChanged: _refilter, + style: theme.textTheme.bodyMedium, + decoration: const InputDecoration( + hintText: 'Type a command…', + prefixIcon: Icon(Icons.search, size: 18), + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + contentPadding: EdgeInsets.symmetric( + horizontal: _T4Space.sm, + vertical: _T4Space.sm, + ), + ), + ), + ), + Divider(height: 1, color: scheme.outlineVariant), + Flexible( + child: _matches.isEmpty + ? Padding( + padding: const EdgeInsets.all(_T4Space.md), + child: Text( + 'No matching commands.', + textAlign: TextAlign.center, + style: theme.textTheme.bodySmall?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ) + : ListView.builder( + controller: _scrollController, + shrinkWrap: true, + padding: const EdgeInsets.symmetric( + vertical: _T4Space.xxs, + ), + itemExtent: 40, + itemCount: _matches.length, + itemBuilder: (context, index) => _PaletteRow( + match: _matches[index], + active: index == _active, + onTap: () => _runCommand(_matches[index].command), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +final class _PaletteRow extends StatelessWidget { + const _PaletteRow({ + required this.match, + required this.active, + required this.onTap, + }); + + final _PaletteMatch match; + final bool active; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final command = match.command; + final enabled = command.enabled; + final baseColor = enabled ? scheme.onSurface : scheme.outline; + final title = Text.rich( + TextSpan( + children: [ + for (var i = 0; i < command.title.length; i++) + TextSpan( + text: command.title[i], + style: match.highlight.contains(i) + ? TextStyle( + color: scheme.primary, + fontWeight: FontWeight.w600, + ) + : null, + ), + ], + style: TextStyle(fontSize: 13, color: baseColor), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ); + return InkWell( + key: ValueKey('palette-command-${command.id}'), + onTap: enabled ? onTap : null, + child: Container( + color: active ? scheme.primary.withValues(alpha: 0.08) : null, + padding: const EdgeInsets.symmetric(horizontal: _T4Space.sm), + alignment: Alignment.centerLeft, + child: Row( + children: [ + Expanded(child: title), + if (command.shortcutLabel case final shortcut?) ...[ + const SizedBox(width: _T4Space.xs), + Container( + padding: const EdgeInsets.symmetric( + horizontal: _T4Space.xxs + 1, + vertical: 1, + ), + decoration: BoxDecoration( + border: Border.all(color: scheme.outlineVariant), + borderRadius: BorderRadius.circular(_T4Radius.xs - 2), + ), + child: Text( + shortcut, + style: TextStyle( + fontFamily: _T4Typography.monoFamily, + fontSize: 11, + color: scheme.onSurfaceVariant, + ), + ), + ), + ], + ], + ), + ), + ); + } +} diff --git a/apps/flutter/lib/src/ui/context_panel.dart b/apps/flutter/lib/src/ui/context_panel.dart new file mode 100644 index 00000000..cf1421fb --- /dev/null +++ b/apps/flutter/lib/src/ui/context_panel.dart @@ -0,0 +1,180 @@ +part of 't4_app.dart'; + +/// One collapsible section hosted by a [ContextPanel]. +final class ContextPanelSection { + const ContextPanelSection({ + required this.id, + required this.title, + required this.child, + this.trailing, + this.initiallyExpanded = true, + }); + + final String id; + final String title; + final Widget child; + final Widget? trailing; + final bool initiallyExpanded; +} + +/// Right-docked contextual side panel with collapsible sections. +/// +/// Renders a fixed-width column with a hairline left divider, one header row +/// per section (title, optional trailing widget, collapse chevron), and a +/// single shared scroll view. Hosts no [Scaffold] or [AppBar] — the shell +/// mounts it directly beside the primary surface. +final class ContextPanel extends StatelessWidget { + const ContextPanel({ + required this.sections, + this.onClose, + this.width = 340, + super.key, + }); + + final List sections; + final VoidCallback? onClose; + final double width; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Container( + width: width, + decoration: BoxDecoration( + color: scheme.surface, + border: Border( + left: BorderSide( + color: scheme.outlineVariant, + width: _T4Size.divider, + ), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (onClose case final close?) + Padding( + padding: const EdgeInsets.fromLTRB( + _T4Space.md, + _T4Space.xs, + _T4Space.xs, + 0, + ), + child: Row( + children: [ + Expanded( + child: Text( + 'Context', + style: Theme.of(context).textTheme.titleSmall, + ), + ), + IconButton( + onPressed: close, + tooltip: 'Close context panel', + iconSize: _T4Size.indicator, + visualDensity: VisualDensity.compact, + color: scheme.onSurfaceVariant, + icon: const Icon(Icons.close), + ), + ], + ), + ), + Expanded( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final section in sections) + _ContextPanelSectionView( + key: ValueKey('context-section-${section.id}'), + section: section, + ), + ], + ), + ), + ), + ], + ), + ); + } +} + +final class _ContextPanelSectionView extends StatefulWidget { + const _ContextPanelSectionView({required this.section, super.key}); + + final ContextPanelSection section; + + @override + State<_ContextPanelSectionView> createState() => + _ContextPanelSectionViewState(); +} + +final class _ContextPanelSectionViewState + extends State<_ContextPanelSectionView> { + late bool _expanded = widget.section.initiallyExpanded; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final section = widget.section; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Semantics( + button: true, + expanded: _expanded, + label: '${section.title} section', + child: InkWell( + onTap: () => setState(() => _expanded = !_expanded), + child: Padding( + padding: const EdgeInsets.fromLTRB( + _T4Space.md, + _T4Space.sm, + _T4Space.sm, + _T4Space.sm, + ), + child: Row( + children: [ + Expanded( + child: Text( + section.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleSmall, + ), + ), + if (section.trailing case final trailing?) ...[ + trailing, + const SizedBox(width: _T4Space.xs), + ], + Icon( + _expanded ? Icons.expand_less : Icons.expand_more, + size: _T4Size.indicator, + color: scheme.onSurfaceVariant, + ), + ], + ), + ), + ), + ), + if (_expanded) + Padding( + padding: const EdgeInsets.fromLTRB( + _T4Space.md, + 0, + _T4Space.md, + _T4Space.sm, + ), + child: section.child, + ), + Divider( + height: _T4Size.divider, + thickness: _T4Size.divider, + color: scheme.outlineVariant, + ), + ], + ); + } +} diff --git a/apps/flutter/lib/src/ui/conversation_pane.dart b/apps/flutter/lib/src/ui/conversation_pane.dart index 411a34ef..25d613fd 100644 --- a/apps/flutter/lib/src/ui/conversation_pane.dart +++ b/apps/flutter/lib/src/ui/conversation_pane.dart @@ -10,6 +10,7 @@ final class _ConversationPane extends StatelessWidget { required this.onOpenAttention, required this.onOpenDeveloper, required this.onOpenQuickOpen, + required this.onSelectSession, }); final T4ViewState state; @@ -20,6 +21,7 @@ final class _ConversationPane extends StatelessWidget { final VoidCallback onOpenAttention; final VoidCallback onOpenDeveloper; final Future Function() onOpenQuickOpen; + final Future Function(String sessionId) onSelectSession; @override Widget build(BuildContext context) { @@ -33,9 +35,10 @@ final class _ConversationPane extends StatelessWidget { if (showHeader) _ConversationHeader( state: state, - onOpenAttention: onOpenAttention, + actions: actions, onOpenDeveloper: onOpenDeveloper, onOpenQuickOpen: onOpenQuickOpen, + onSelectSession: onSelectSession, ), if (showError) _ConnectionErrorBanner( @@ -50,6 +53,8 @@ final class _ConversationPane extends StatelessWidget { state: state, actions: actions, onOpenSessions: onOpenSessions, + onOpenAttention: onOpenAttention, + onOpenDeveloper: onOpenDeveloper, ), ), _PromptComposer(state: state, actions: actions), @@ -58,22 +63,35 @@ final class _ConversationPane extends StatelessWidget { } } -final class _ConversationHeader extends StatelessWidget { +/// Wide-layout conversation header. Only the wide shell renders this widget +/// (compact keeps the app bar), so the inbox button anchors a 400 px flyout +/// here instead of toggling the full-screen attention takeover. +final class _ConversationHeader extends StatefulWidget { const _ConversationHeader({ required this.state, - required this.onOpenAttention, + required this.actions, required this.onOpenDeveloper, required this.onOpenQuickOpen, + required this.onSelectSession, }); final T4ViewState state; - final VoidCallback onOpenAttention; + final T4Actions actions; final VoidCallback onOpenDeveloper; final Future Function() onOpenQuickOpen; + final Future Function(String sessionId) onSelectSession; + + @override + State<_ConversationHeader> createState() => _ConversationHeaderState(); +} + +final class _ConversationHeaderState extends State<_ConversationHeader> { + final MenuController _inboxMenu = MenuController(); @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; + final state = widget.state; final session = state.selectedSession; final streaming = state.composer.turnActive; @@ -120,23 +138,43 @@ final class _ConversationHeader extends StatelessWidget { state.grantedCapabilities.contains('files.list') && state.grantedCapabilities.contains('files.read') && session != null - ? () => unawaited(onOpenQuickOpen()) + ? () => unawaited(widget.onOpenQuickOpen()) : null, tooltip: 'Quick open project file', icon: const Icon(Icons.search), ), IconButton( - onPressed: onOpenDeveloper, + onPressed: widget.onOpenDeveloper, tooltip: 'Open developer tools', icon: const Icon(Icons.code), ), Badge( isLabelVisible: state.urgentAttentionCount > 0, label: Text('${state.urgentAttentionCount}'), - child: IconButton( - onPressed: onOpenAttention, - tooltip: 'Open inbox', - icon: const Icon(Icons.inbox_outlined), + child: MenuAnchor( + controller: _inboxMenu, + alignmentOffset: const Offset(0, _T4Space.xs), + menuChildren: [ + SizedBox( + width: 400, + height: 480, + child: InboxFlyoutContent( + state: state, + actions: widget.actions, + showTitle: false, + onDone: _inboxMenu.close, + onOpenSession: (sessionId) async { + await widget.onSelectSession(sessionId); + if (mounted) _inboxMenu.close(); + }, + ), + ), + ], + builder: (context, controller, child) => IconButton( + onPressed: () => _toggleMenu(controller), + tooltip: 'Open inbox', + icon: const Icon(Icons.inbox_outlined), + ), ), ), const SizedBox(width: _T4Space.sm), @@ -239,12 +277,16 @@ final class _TranscriptView extends StatefulWidget { const _TranscriptView({ required this.state, required this.actions, + required this.onOpenAttention, this.onOpenSessions, + this.onOpenDeveloper, }); final T4ViewState state; final T4Actions actions; + final VoidCallback onOpenAttention; final VoidCallback? onOpenSessions; + final VoidCallback? onOpenDeveloper; @override State<_TranscriptView> createState() => _TranscriptViewState(); @@ -252,6 +294,8 @@ final class _TranscriptView extends StatefulWidget { final class _TranscriptViewState extends State<_TranscriptView> { final ScrollController _scrollController = ScrollController(); + final Set _expandedWorkGroups = {}; + final Set _respondingAttention = {}; bool _followEnd = true; bool _userScrolling = false; @@ -381,6 +425,125 @@ final class _TranscriptViewState extends State<_TranscriptView> { }); } + /// Collapses each maximal run of two or more completed tool/reasoning + /// steps between chat messages into one collapsible work group. The + /// trailing run of the currently streaming turn stays expanded/ungrouped. + List<_TranscriptEntry> _transcriptEntries() { + final messages = widget.state.messages; + final entries = <_TranscriptEntry>[]; + var index = 0; + while (index < messages.length) { + if (!_isWorkedMessage(messages[index])) { + entries.add(_SingleTranscriptEntry(messages[index])); + index += 1; + continue; + } + var end = index; + while (end < messages.length && _isWorkedMessage(messages[end])) { + end += 1; + } + final run = messages.sublist(index, end); + final trailingActiveRun = + end == messages.length && widget.state.composer.turnActive; + if (run.length < 2 || trailingActiveRun) { + entries.addAll(run.map(_SingleTranscriptEntry.new)); + } else { + entries.add(_WorkGroupTranscriptEntry(_TranscriptWorkGroup(run))); + } + index = end; + } + return entries; + } + + void _toggleWorkGroup(String key) { + setState(() { + if (!_expandedWorkGroups.remove(key)) _expandedWorkGroups.add(key); + }); + } + + void _showError(String message) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar(SnackBar(content: Text(message))); + } + + /// Mirrors the inbox pane's respond wiring (busy set, acceptance check, + /// error surfacing) for the inline approval cards. + Future _respondAttention( + AttentionItem item, + AttentionResponse response, + ) async { + if (!_respondingAttention.add(item.key)) return; + setState(() {}); + try { + final accepted = await widget.actions.respondToAttention(item, response); + if (!mounted) return; + if (!accepted) _showError('The host did not accept that response.'); + } on Object catch (error) { + if (mounted) _showError(error.toString().replaceFirst('Bad state: ', '')); + } finally { + if (mounted) setState(() => _respondingAttention.remove(item.key)); + } + } + + Future _askForText({ + required String title, + String hint = 'Add a note', + }) async { + final controller = TextEditingController(); + final result = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(title), + content: TextField( + controller: controller, + autofocus: true, + minLines: 2, + maxLines: 6, + decoration: InputDecoration(hintText: hint), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, controller.text.trim()), + child: const Text('Send'), + ), + ], + ), + ); + controller.dispose(); + return result; + } + + Future _reviseAttention(AttentionItem item) async { + final note = await _askForText( + title: 'Request plan changes', + hint: 'What should change?', + ); + if (note != null) { + await _respondAttention( + item, + AttentionResponse(decision: AttentionDecision.revise, text: note), + ); + } + } + + Future _customAnswerAttention(AttentionItem item) async { + final answer = await _askForText( + title: item.title, + hint: 'Type your answer', + ); + if (answer != null && answer.isNotEmpty) { + await _respondAttention( + item, + AttentionResponse(decision: AttentionDecision.approve, text: answer), + ); + } + } + @override void dispose() { _scrollController.dispose(); @@ -416,6 +579,15 @@ final class _TranscriptViewState extends State<_TranscriptView> { widget.state.transcriptHistoryHasMore || widget.state.transcriptHistoryError != null; final messageOffset = showHistoryControl ? 1 : 0; + final entries = _transcriptEntries(); + final inlineAttention = widget.state.attentionItems + .where( + (item) => + item.sessionId == widget.state.selectedSessionId && + item.needsResponse && + item.actionable, + ) + .toList(growable: false); return Column( children: [ @@ -438,21 +610,65 @@ final class _TranscriptViewState extends State<_TranscriptView> { _T4Space.md, _T4Space.xl, ), - itemCount: widget.state.messages.length + messageOffset, + itemCount: + entries.length + messageOffset + inlineAttention.length, separatorBuilder: (context, index) => const SizedBox(height: _T4Space.lg), itemBuilder: (context, index) { + final Widget item; if (showHistoryControl && index == 0) { - return _TranscriptHistoryControl( + item = _TranscriptHistoryControl( loading: widget.state.transcriptHistoryLoading, hasMore: widget.state.transcriptHistoryHasMore, error: widget.state.transcriptHistoryError, onLoad: widget.actions.loadEarlierTranscript, ); + } else if (index - messageOffset < entries.length) { + item = switch (entries[index - messageOffset]) { + _SingleTranscriptEntry(:final message) => + _TranscriptMessageView( + message: message, + actions: widget.actions, + ), + _WorkGroupTranscriptEntry(:final group) => + _WorkGroupView( + group: group, + expanded: _expandedWorkGroups.contains(group.key), + onToggle: () => _toggleWorkGroup(group.key), + actions: widget.actions, + onReview: widget.onOpenDeveloper, + ), + }; + } else { + final attention = + inlineAttention[index - + messageOffset - + entries.length]; + item = InlineApprovalCard( + item: attention, + busy: _respondingAttention.contains(attention.key), + canRespond: + widget.state.connectionPhase == + ConnectionPhase.ready && + attention.actionable, + onRespond: (response) => + unawaited(_respondAttention(attention, response)), + onRevise: () => unawaited(_reviseAttention(attention)), + onCustomAnswer: () => + unawaited(_customAnswerAttention(attention)), + onOpenInbox: widget.onOpenAttention, + ); } - return _TranscriptMessageView( - message: widget.state.messages[index - messageOffset], - actions: widget.actions, + // Share one centered column axis with the composer: the + // transcript content is capped at the same + // [_T4Layout.contentMaxWidth] the composer uses. + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: _T4Layout.contentMaxWidth, + ), + child: SizedBox(width: double.infinity, child: item), + ), ); }, ), @@ -465,6 +681,244 @@ final class _TranscriptViewState extends State<_TranscriptView> { } } +/// A transcript row: either one message or a collapsed run of work steps. +sealed class _TranscriptEntry { + const _TranscriptEntry(); +} + +final class _SingleTranscriptEntry extends _TranscriptEntry { + const _SingleTranscriptEntry(this.message); + + final TranscriptMessage message; +} + +final class _WorkGroupTranscriptEntry extends _TranscriptEntry { + const _WorkGroupTranscriptEntry(this.group); + + final _TranscriptWorkGroup group; +} + +final class _TranscriptWorkGroup { + _TranscriptWorkGroup(this.messages) + : editedFiles = _editedFilePaths(messages); + + final List messages; + + /// Best-effort file paths touched by file-mutating tool calls in the group. + final List editedFiles; + + String get key => messages.first.id; +} + +/// Whether a message renders as pure background work (a completed tool card +/// or a text-less reasoning disclosure) and may collapse into a work group. +bool _isWorkedMessage(TranscriptMessage message) { + if (message.streaming) return false; + if (message.kind == TranscriptKind.tool) return !message.toolRunning; + return message.kind == TranscriptKind.message && + message.role == MessageRole.assistant && + message.text.isEmpty && + message.images.isEmpty && + message.reasoning.isNotEmpty; +} + +/// Matches the file-mutating wire commands (`files.write`, `files.patch`, +/// `review.apply`, apply_patch-style names) without inventing new ones. +bool _isFileMutatingToolName(String? name) { + if (name == null) return false; + final lower = name.toLowerCase(); + if (lower == 'review.apply' || lower.contains('apply_patch')) return true; + if (!lower.startsWith('files.')) return false; + final verb = lower.substring('files.'.length); + return verb.startsWith('write') || + verb.startsWith('patch') || + verb.startsWith('apply'); +} + +/// Derives file paths from tool-call argument JSON ('path'/'file'/'files'). +List _editedFilePaths(List messages) { + final paths = {}; + for (final message in messages) { + if (message.kind != TranscriptKind.tool) continue; + if (!_isFileMutatingToolName(message.toolName)) continue; + final raw = message.toolArguments; + if (raw == null || raw.isEmpty) continue; + Object? decoded; + try { + decoded = jsonDecode(raw); + } on FormatException { + continue; + } + if (decoded is! Map) continue; + void addPath(Object? value) { + if (value is String && value.trim().isNotEmpty) paths.add(value.trim()); + } + + addPath(decoded['path']); + addPath(decoded['file']); + if (decoded['files'] case final List files) { + for (final entry in files) { + if (entry is Map) { + addPath(entry['path']); + addPath(entry['file']); + } else { + addPath(entry); + } + } + } + } + return paths.toList(growable: false); +} + +/// Collapsible 'Worked · N steps' row wrapping a run of completed steps. +/// Expanded rendering is identical to the ungrouped transcript. +final class _WorkGroupView extends StatelessWidget { + const _WorkGroupView({ + required this.group, + required this.expanded, + required this.onToggle, + required this.actions, + this.onReview, + }); + + final _TranscriptWorkGroup group; + final bool expanded; + final VoidCallback onToggle; + final T4Actions actions; + final VoidCallback? onReview; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final labelStyle = Theme.of( + context, + ).textTheme.labelSmall?.copyWith(color: scheme.onSurfaceVariant); + final steps = group.messages.length; + final edited = group.editedFiles; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: _T4Space.xxs), + child: Semantics( + button: true, + label: + 'Worked · $steps steps, ${expanded ? 'expanded' : 'collapsed'}', + child: InkWell( + borderRadius: BorderRadius.circular(_T4Radius.sm), + onTap: onToggle, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: _T4Space.xxs, + vertical: _T4Space.xxs, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + expanded ? Icons.expand_more : Icons.chevron_right, + size: 14, + color: scheme.onSurfaceVariant, + ), + const SizedBox(width: _T4Space.xxs), + Text('Worked · $steps steps', style: labelStyle), + ], + ), + ), + ), + ), + ), + if (edited.isNotEmpty) + Padding( + padding: const EdgeInsets.only( + left: _T4Space.xxs, + bottom: _T4Space.xxs, + ), + child: Wrap( + spacing: _T4Space.xs, + runSpacing: _T4Space.xxs, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Text( + edited.length == 1 + ? 'Edited 1 file' + : 'Edited ${edited.length} files', + style: labelStyle, + ), + for (final path in edited.take(3)) _EditedFileChip(path: path), + if (edited.length > 3) + Text('+${edited.length - 3} more', style: labelStyle), + if (onReview != null) + TextButton( + onPressed: onReview, + style: TextButton.styleFrom( + foregroundColor: scheme.onSurfaceVariant, + textStyle: const TextStyle(fontSize: 12), + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.symmetric( + horizontal: _T4Space.xs, + ), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: const Text('Review'), + ), + ], + ), + ), + if (expanded) ...[ + const SizedBox(height: _T4Space.xs), + for (var index = 0; index < group.messages.length; index++) ...[ + if (index > 0) const SizedBox(height: _T4Space.lg), + _TranscriptMessageView( + message: group.messages[index], + actions: actions, + ), + ], + ], + ], + ); + } +} + +final class _EditedFileChip extends StatelessWidget { + const _EditedFileChip({required this.path}); + + final String path; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final basename = path.split('/').last.split('\\').last; + return Tooltip( + message: path, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: _T4Space.xs, + vertical: 1, + ), + decoration: BoxDecoration( + color: scheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(_T4Radius.sm), + ), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 140), + child: Text( + basename, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontFamily: _T4Typography.monoFamily, + fontSize: 11, + color: scheme.onSurfaceVariant, + ), + ), + ), + ), + ); + } +} + final class _CachedTranscriptNotice extends StatelessWidget { const _CachedTranscriptNotice(); @@ -622,12 +1076,77 @@ final class _TranscriptMessageView extends StatelessWidget { final label = message.kind == TranscriptKind.compaction ? 'Earlier chat summary' : message.role.label; + // User and assistant messages carry no visible role text; screen readers + // still announce the role through the enclosing [Semantics] label. + final showVisualLabel = + isAuxiliary || message.kind == TranscriptKind.compaction; final background = isUser ? scheme.surfaceContainerHigh : isAuxiliary ? scheme.surfaceContainerLow : Colors.transparent; + Widget block = DecoratedBox( + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(_T4Radius.md), + border: isAuxiliary ? Border.all(color: scheme.outlineVariant) : null, + ), + child: Padding( + padding: isUser || isAuxiliary + ? const EdgeInsets.all(_T4Space.md) + : const EdgeInsets.symmetric(vertical: _T4Space.xs), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showVisualLabel) + Text( + label.toUpperCase(), + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + if (message.reasoning.isNotEmpty) ...[ + if (showVisualLabel) const SizedBox(height: _T4Space.xs), + _ReasoningDisclosure( + reasoning: message.reasoning, + streaming: message.streaming, + ), + ], + if (message.text.isNotEmpty) ...[ + if (showVisualLabel || message.reasoning.isNotEmpty) + const SizedBox(height: _T4Space.xs), + TranscriptMarkdown(data: message.text), + ], + if (message.images.isNotEmpty) ...[ + const SizedBox(height: _T4Space.sm), + Wrap( + spacing: _T4Space.sm, + runSpacing: _T4Space.sm, + children: [ + for (final image in message.images) + _TranscriptImage( + entryId: message.id, + image: image, + actions: actions, + ), + ], + ), + ], + if (message.streaming) ...[ + const SizedBox(height: _T4Space.sm), + const _StreamingLabel(), + ], + ], + ), + ), + ); + if (message.role == MessageRole.assistant && + message.kind == TranscriptKind.message && + message.text.isNotEmpty) { + block = _MessageCopyAffordance(markdown: message.text, child: block); + } + return Align( alignment: isUser ? Alignment.centerRight : Alignment.centerLeft, child: ConstrainedBox( @@ -635,61 +1154,91 @@ final class _TranscriptMessageView extends StatelessWidget { child: Semantics( container: true, label: '$label message${message.streaming ? ', streaming' : ''}', - child: DecoratedBox( - decoration: BoxDecoration( - color: background, - borderRadius: BorderRadius.circular(_T4Radius.md), - border: isAuxiliary - ? Border.all(color: scheme.outlineVariant) - : null, - ), - child: Padding( - padding: isUser || isAuxiliary - ? const EdgeInsets.all(_T4Space.md) - : const EdgeInsets.symmetric(vertical: _T4Space.xs), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label.toUpperCase(), - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: scheme.onSurfaceVariant, - ), + child: block, + ), + ), + ); + } +} + +/// Hover-revealed (or long-press-revealed on touch) copy affordance for an +/// assistant message; copies the raw markdown source. Icon swaps to a check +/// briefly, matching the [CodeBlock] copy pattern. +final class _MessageCopyAffordance extends StatefulWidget { + const _MessageCopyAffordance({required this.markdown, required this.child}); + + final String markdown; + final Widget child; + + @override + State<_MessageCopyAffordance> createState() => _MessageCopyAffordanceState(); +} + +final class _MessageCopyAffordanceState extends State<_MessageCopyAffordance> { + static const Duration _copiedFeedback = Duration(milliseconds: 1500); + static const Duration _longPressReveal = Duration(seconds: 4); + + bool _hovering = false; + bool _revealed = false; + bool _copied = false; + Timer? _copyTimer; + Timer? _revealTimer; + + @override + void dispose() { + _copyTimer?.cancel(); + _revealTimer?.cancel(); + super.dispose(); + } + + void _revealFromLongPress() { + setState(() => _revealed = true); + _revealTimer?.cancel(); + _revealTimer = Timer(_longPressReveal, () { + if (mounted) setState(() => _revealed = false); + }); + } + + Future _copy() async { + await Clipboard.setData(ClipboardData(text: widget.markdown)); + if (!mounted) return; + setState(() => _copied = true); + _copyTimer?.cancel(); + _copyTimer = Timer(_copiedFeedback, () { + if (mounted) setState(() => _copied = false); + }); + } + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final visible = _hovering || _revealed || _copied; + return MouseRegion( + onEnter: (_) => setState(() => _hovering = true), + onExit: (_) => setState(() => _hovering = false), + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onLongPress: _revealFromLongPress, + child: Stack( + clipBehavior: Clip.none, + children: [ + widget.child, + if (visible) + Positioned( + top: 0, + right: 0, + child: IconButton( + onPressed: () => unawaited(_copy()), + tooltip: _copied ? 'Copied' : 'Copy message', + iconSize: 14, + visualDensity: VisualDensity.compact, + icon: Icon( + _copied ? Icons.check_rounded : Icons.copy_rounded, + color: _copied ? scheme.primary : scheme.onSurfaceVariant, ), - if (message.reasoning.isNotEmpty) ...[ - const SizedBox(height: _T4Space.xs), - _ReasoningDisclosure( - reasoning: message.reasoning, - streaming: message.streaming, - ), - ], - if (message.text.isNotEmpty) ...[ - const SizedBox(height: _T4Space.xs), - MarkdownBody(data: message.text, selectable: true), - ], - if (message.images.isNotEmpty) ...[ - const SizedBox(height: _T4Space.sm), - Wrap( - spacing: _T4Space.sm, - runSpacing: _T4Space.sm, - children: [ - for (final image in message.images) - _TranscriptImage( - entryId: message.id, - image: image, - actions: actions, - ), - ], - ), - ], - if (message.streaming) ...[ - const SizedBox(height: _T4Space.sm), - const _StreamingLabel(), - ], - ], + ), ), - ), - ), + ], ), ), ); @@ -728,7 +1277,7 @@ final class _ReasoningDisclosure extends StatelessWidget { _T4Space.md, _T4Space.md, ), - children: [MarkdownBody(data: reasoning, selectable: true)], + children: [TranscriptMarkdown(data: reasoning)], ), ); } @@ -1155,10 +1704,7 @@ final class _PromptComposerState extends State<_PromptComposer> { .toList(growable: false); return DecoratedBox( - decoration: BoxDecoration( - color: scheme.surface, - border: Border(top: BorderSide(color: scheme.outlineVariant)), - ), + decoration: BoxDecoration(color: scheme.surface), child: SafeArea( top: false, minimum: const EdgeInsets.fromLTRB( @@ -1185,13 +1731,18 @@ final class _PromptComposerState extends State<_PromptComposer> { for (final command in slashCommands) ListTile( dense: true, + visualDensity: VisualDensity.compact, enabled: command.disabledReason == null, - title: Text(command.name), + title: Text( + command.name, + style: const TextStyle(fontSize: 12), + ), subtitle: Text( command.disabledReason ?? (command.aliases.isEmpty ? command.description : '${command.description} · ${command.aliases.join(' ')}'), + style: const TextStyle(fontSize: 12), ), onTap: command.disabledReason == null ? () => _selectSlashCommand(command) @@ -1200,152 +1751,67 @@ final class _PromptComposerState extends State<_PromptComposer> { ], ), ), - if (_currentAttachments.isNotEmpty) - SizedBox( - height: 72, - child: ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: _currentAttachments.length, - separatorBuilder: (context, index) => - const SizedBox(width: _T4Space.xs), - itemBuilder: (context, index) { - final attachment = _currentAttachments[index]; - return InputChip( - avatar: ClipRRect( - borderRadius: BorderRadius.circular(_T4Radius.sm), - child: Image.memory( - attachment.bytes, - width: 40, - height: 40, - fit: BoxFit.cover, - ), - ), - label: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 120), - child: Text( - attachment.name, - overflow: TextOverflow.ellipsis, - ), - ), - onDeleted: _sending - ? null - : () => _removeAttachment(attachment.id), - ); - }, - ), + // Single Codex-style composer container: text field on top, + // controls embedded along the bottom edge. + Container( + decoration: BoxDecoration( + color: scheme.surface, + borderRadius: BorderRadius.circular(_composerRadius), + border: Border.all(color: scheme.outlineVariant), ), - Wrap( - spacing: _T4Space.xs, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - _ModelSelectorMenu( - composer: composer, - onSelect: (selector) => unawaited( - _runControl( - () => widget.actions.setSessionModel(selector), - ), - ), - ), - PopupMenuButton( - tooltip: 'Choose thinking level', - enabled: composer.thinkingLevels.isNotEmpty, - onSelected: (level) => unawaited( - _runControl( - () => widget.actions.setSessionThinking(level), - ), - ), - itemBuilder: (context) => [ - for (final level in composer.thinkingLevels) - PopupMenuItem( - value: level, - child: Text(level), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (_currentAttachments.isNotEmpty) + Padding( + padding: const EdgeInsets.fromLTRB( + _T4Space.sm, + _T4Space.xs, + _T4Space.sm, + 0, ), - ], - child: Chip( - avatar: const Icon(Icons.psychology_outlined, size: 20), - label: Text(composer.thinking ?? 'Thinking'), - ), - ), - if (composer.fastAvailable) - FilterChip( - label: Text( - 'Fast', - style: TextStyle( - color: composer.fastEnabled - ? null - : scheme.onSurfaceVariant, - ), - ), - selected: composer.fastEnabled, - onSelected: (enabled) => unawaited( - _runControl( - () => widget.actions.setSessionFast(enabled), + child: SizedBox( + height: 72, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: _currentAttachments.length, + separatorBuilder: (context, index) => + const SizedBox(width: _T4Space.xs), + itemBuilder: (context, index) { + final attachment = _currentAttachments[index]; + return InputChip( + visualDensity: VisualDensity.compact, + labelStyle: const TextStyle(fontSize: 12), + avatar: ClipRRect( + borderRadius: BorderRadius.circular( + _T4Radius.sm, + ), + child: Image.memory( + attachment.bytes, + width: 40, + height: 40, + fit: BoxFit.cover, + ), + ), + label: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 120, + ), + child: Text( + attachment.name, + overflow: TextOverflow.ellipsis, + ), + ), + onDeleted: _sending + ? null + : () => _removeAttachment(attachment.id), + ); + }, + ), ), ), - ), - if (showControls && composer.isPaused) - ActionChip( - avatar: const Icon(Icons.play_arrow, size: 20), - label: const Text('Resume'), - onPressed: canControl - ? () => unawaited( - _runControl(widget.actions.resumeSession), - ) - : null, - ) - else if (showControls && composer.turnActive) - ActionChip( - avatar: const Icon(Icons.pause, size: 20), - label: const Text('Pause'), - onPressed: canControl - ? () => unawaited( - _runControl(widget.actions.pauseSession), - ) - : null, - ) - else if (showControls) - ActionChip( - avatar: const Icon(Icons.compress, size: 20), - label: const Text('Compact'), - onPressed: canControl - ? () => unawaited( - _runControl(widget.actions.compactSession), - ) - : null, - ), - if (composer.turnActive) ...[ - ActionChip( - avatar: const Icon(Icons.stop, size: 20), - label: const Text('Stop'), - onPressed: () => - unawaited(_runControl(widget.actions.cancelTurn)), - ), - ActionChip( - avatar: const Icon(Icons.playlist_add, size: 20), - label: Text( - composer.queuedFollowUpCount == 0 - ? 'Queue' - : 'Queue (${composer.queuedFollowUpCount})', - ), - onPressed: _canQueue ? () => unawaited(_queue()) : null, - ), - ], - ], - ), - const SizedBox(height: _T4Space.xs), - Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - IconButton( - tooltip: 'Attach image', - onPressed: - _ready && _currentAttachments.length < _maximumImages - ? () => unawaited(_pickImages()) - : null, - icon: const Icon(Icons.attach_file), - ), - Expanded( - child: CallbackShortcuts( + CallbackShortcuts( bindings: { const SingleActivator(LogicalKeyboardKey.enter): () => unawaited(_submit()), @@ -1369,25 +1835,178 @@ final class _PromptComposerState extends State<_PromptComposer> { : composer.turnActive ? 'Steer the active turn' : 'Message T4', + isDense: true, + filled: false, + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + disabledBorder: InputBorder.none, + contentPadding: const EdgeInsets.fromLTRB( + _T4Space.md, + _T4Space.sm, + _T4Space.md, + _T4Space.xs, + ), ), ), ), ), - ), - const SizedBox(width: _T4Space.xs), - FilledButton( - onPressed: _canSubmit ? () => unawaited(_submit()) : null, - child: _sending - ? const SizedBox.square( - dimension: _T4Size.indicator, - child: CircularProgressIndicator( - strokeWidth: _T4Size.thinStroke, - semanticsLabel: 'Sending', - ), - ) - : Text(composer.turnActive ? 'Steer' : 'Send'), - ), - ], + Padding( + padding: const EdgeInsets.fromLTRB( + _T4Space.xxs, + 0, + _T4Space.xs, + _T4Space.xxs, + ), + child: Wrap( + alignment: WrapAlignment.spaceBetween, + crossAxisAlignment: WrapCrossAlignment.center, + runSpacing: _T4Space.xxs, + children: [ + Wrap( + spacing: _T4Space.xxs, + runSpacing: _T4Space.xxs, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + IconButton( + tooltip: 'Attach image', + visualDensity: VisualDensity.compact, + iconSize: 18, + onPressed: + _ready && + _currentAttachments.length < + _maximumImages + ? () => unawaited(_pickImages()) + : null, + icon: const Icon(Icons.attach_file), + ), + if (showControls && composer.isPaused) + _ComposerQuietButton( + icon: Icons.play_arrow, + label: 'Resume', + onPressed: canControl + ? () => unawaited( + _runControl( + widget.actions.resumeSession, + ), + ) + : null, + ) + else if (showControls && composer.turnActive) + _ComposerQuietButton( + icon: Icons.pause, + label: 'Pause', + onPressed: canControl + ? () => unawaited( + _runControl( + widget.actions.pauseSession, + ), + ) + : null, + ), + if (composer.turnActive) ...[ + _ComposerQuietButton( + icon: Icons.stop, + label: 'Stop', + onPressed: () => unawaited( + _runControl(widget.actions.cancelTurn), + ), + ), + _ComposerQuietButton( + icon: Icons.playlist_add, + label: composer.queuedFollowUpCount == 0 + ? 'Queue' + : 'Queue (${composer.queuedFollowUpCount})', + onPressed: _canQueue + ? () => unawaited(_queue()) + : null, + ), + ], + ], + ), + Wrap( + spacing: _T4Space.xxs, + runSpacing: _T4Space.xxs, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + _AccessSummaryPill(state: widget.state), + _ThinkingSelectorMenu( + composer: composer, + onSelect: (level) => unawaited( + _runControl( + () => widget.actions.setSessionThinking( + level, + ), + ), + ), + ), + _ModelSelectorMenu( + composer: composer, + onSelect: (selector) => unawaited( + _runControl( + () => widget.actions.setSessionModel( + selector, + ), + ), + ), + onToggleFast: (enabled) => unawaited( + _runControl( + () => widget.actions.setSessionFast( + enabled, + ), + ), + ), + showCompact: + showControls && + !composer.isPaused && + !composer.turnActive, + onCompact: canControl + ? () => unawaited( + _runControl( + widget.actions.compactSession, + ), + ) + : null, + ), + SizedBox.square( + dimension: _composerSendSize, + child: IconButton.filled( + tooltip: composer.turnActive + ? 'Steer' + : 'Send', + style: IconButton.styleFrom( + minimumSize: const Size.square( + _composerSendSize, + ), + fixedSize: const Size.square( + _composerSendSize, + ), + padding: EdgeInsets.zero, + tapTargetSize: + MaterialTapTargetSize.shrinkWrap, + ), + iconSize: 16, + onPressed: _canSubmit + ? () => unawaited(_submit()) + : null, + icon: _sending + ? const SizedBox.square( + dimension: 14, + child: CircularProgressIndicator( + strokeWidth: _T4Size.thinStroke, + semanticsLabel: 'Sending', + ), + ) + : const Icon(Icons.arrow_upward), + ), + ), + ], + ), + ], + ), + ), + ], + ), ), ], ), @@ -1398,6 +2017,181 @@ final class _PromptComposerState extends State<_PromptComposer> { } } +/// Composer control metrics shared by the redesigned prompt container. +const double _composerRadius = 12; +const double _composerSendSize = 32; + +/// Shared style for composer selector menus. The pills sit on the bottom edge +/// of a bottom-docked composer, so the menu layout always flips the surface +/// upward above the anchor (with the [Offset] gap) instead of clipping below +/// or overlapping the text field row. +const MenuStyle _composerMenuStyle = MenuStyle( + visualDensity: VisualDensity.compact, + maximumSize: WidgetStatePropertyAll(Size(280, 360)), +); +const Offset _composerMenuOffset = Offset(0, _T4Space.xs); + +final ButtonStyle _composerMenuItemStyle = MenuItemButton.styleFrom( + visualDensity: VisualDensity.compact, + textStyle: const TextStyle(fontSize: 12), + minimumSize: const Size.fromHeight(36), +); + +/// Quiet 12 px text control for the composer's turn controls +/// (pause/resume/stop/queue). +final class _ComposerQuietButton extends StatelessWidget { + const _ComposerQuietButton({ + required this.icon, + required this.label, + required this.onPressed, + }); + + final IconData icon; + final String label; + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return TextButton( + onPressed: onPressed, + style: TextButton.styleFrom( + foregroundColor: scheme.onSurfaceVariant, + textStyle: const TextStyle(fontSize: 12), + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.symmetric( + horizontal: _T4Space.xs, + vertical: _T4Space.xxs, + ), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + shape: const StadiumBorder(), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14), + const SizedBox(width: _T4Space.xxs), + Flexible( + child: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis), + ), + ], + ), + ); + } +} + +/// Quiet pill anchor shared by the thinking and model selector menus. +final class _ComposerPill extends StatelessWidget { + const _ComposerPill({ + required this.label, + required this.onTap, + this.maxLabelWidth, + this.tooltip, + }); + + final String label; + final VoidCallback? onTap; + final double? maxLabelWidth; + final String? tooltip; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final enabled = onTap != null; + final color = enabled ? scheme.onSurfaceVariant : scheme.outline; + Widget pill = Material( + color: Colors.transparent, + shape: const StadiumBorder(), + child: InkWell( + customBorder: const StadiumBorder(), + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: _T4Space.xs, + vertical: _T4Space.xxs + 1, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + ConstrainedBox( + constraints: BoxConstraints( + maxWidth: maxLabelWidth ?? double.infinity, + ), + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 12, color: color), + ), + ), + const SizedBox(width: 2), + Icon(Icons.keyboard_arrow_up, size: 14, color: color), + ], + ), + ), + ), + ); + if (tooltip case final message?) { + pill = Tooltip(message: message, child: pill); + } + return pill; + } +} + +/// Thinking-level selector as a quiet pill; the menu opens upward above the +/// pill and never overlaps the text field. +final class _ThinkingSelectorMenu extends StatelessWidget { + const _ThinkingSelectorMenu({required this.composer, required this.onSelect}); + + final SessionComposerState composer; + final ValueChanged onSelect; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final enabled = composer.thinkingLevels.isNotEmpty; + return Semantics( + label: 'Thinking: ${composer.thinking ?? 'Default'}', + button: true, + child: MenuAnchor( + style: _composerMenuStyle, + alignmentOffset: _composerMenuOffset, + menuChildren: [ + for (final level in composer.thinkingLevels) + MenuItemButton( + key: ValueKey('thinking-$level'), + style: _composerMenuItemStyle, + leadingIcon: composer.thinking == level + ? Icon( + Icons.check, + size: 16, + color: theme.colorScheme.primary, + ) + : null, + onPressed: () => onSelect(level), + child: Text(level), + ), + ], + builder: (context, controller, child) => _ComposerPill( + label: composer.thinking ?? 'Thinking', + maxLabelWidth: 88, + tooltip: 'Choose thinking level', + onTap: enabled ? () => _toggleMenu(controller) : null, + ), + ), + ); + } +} + +void _toggleMenu(MenuController controller) { + if (controller.isOpen) { + controller.close(); + } else { + controller.open(); + } +} + /// Provider-grouped model selector for the composer. /// /// Replaces the giant flat catalog list with a navigable provider → model menu @@ -1406,87 +2200,93 @@ final class _PromptComposerState extends State<_PromptComposer> { /// the submitted values. Unknown providers/models stay selectable under an /// 'Other models' group. A single-provider host collapses to a flat list so /// the user is never forced through a one-item submenu. +/// +/// The menu also hosts the session's Fast toggle (checkable item) and the +/// manual Compact action, so the pill stays enabled whenever any of those +/// entries are present. final class _ModelSelectorMenu extends StatelessWidget { - const _ModelSelectorMenu({required this.composer, required this.onSelect}); + const _ModelSelectorMenu({ + required this.composer, + required this.onSelect, + this.onToggleFast, + this.showCompact = false, + this.onCompact, + }); final SessionComposerState composer; final ValueChanged onSelect; + /// Receives the requested Fast state; the item only renders when the host + /// advertises Fast availability. + final ValueChanged? onToggleFast; + final bool showCompact; + final VoidCallback? onCompact; + @override Widget build(BuildContext context) { final groups = composer.modelGroups; - final enabled = groups.any((group) => group.choices.isNotEmpty); + final hasModels = groups.any((group) => group.choices.isNotEmpty); + final showFast = composer.fastAvailable && onToggleFast != null; + final enabled = hasModels || showFast || showCompact; final theme = Theme.of(context); - final menuStyle = MenuStyle( - visualDensity: VisualDensity.compact, - maximumSize: const WidgetStatePropertyAll(Size(280, 360)), - ); - final itemStyle = MenuItemButton.styleFrom( - visualDensity: VisualDensity.compact, - minimumSize: const Size.fromHeight(44), - ); return Semantics( label: 'Model: ${composer.modelLabel ?? 'None'}', button: true, child: MenuAnchor( - style: menuStyle, - alignmentOffset: const Offset(0, 8), + style: _composerMenuStyle, + alignmentOffset: _composerMenuOffset, menuChildren: [ if (groups.length == 1) for (final choice in groups.single.choices) - _modelItem(choice, theme, itemStyle) + _modelItem(choice, theme) else for (final group in groups) if (group.choices.isNotEmpty) SubmenuButton( key: ValueKey('model-provider-${group.provider}'), - style: itemStyle, - menuStyle: menuStyle, + style: _composerMenuItemStyle, + menuStyle: _composerMenuStyle, leadingIcon: const Icon(Icons.folder_outlined, size: 18), menuChildren: [ for (final choice in group.choices) - _modelItem(choice, theme, itemStyle), + _modelItem(choice, theme), ], child: Text(group.label, overflow: TextOverflow.ellipsis), ), - ], - builder: (context, controller, child) { - return ActionChip( - avatar: const Icon(Icons.memory, size: 20), - label: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 140), - child: Text( - composer.modelLabel ?? 'Model', - overflow: TextOverflow.ellipsis, - ), + if (hasModels && (showFast || showCompact)) const Divider(height: 1), + if (showFast) + CheckboxMenuButton( + value: composer.fastEnabled, + style: _composerMenuItemStyle, + onChanged: (checked) => onToggleFast!(checked ?? false), + child: const Text('Fast'), ), - onPressed: enabled ? () => _open(controller) : null, - ); - }, + if (showCompact) + MenuItemButton( + style: _composerMenuItemStyle, + leadingIcon: const Icon(Icons.compress, size: 16), + onPressed: onCompact, + child: const Text('Compact'), + ), + ], + builder: (context, controller, child) => _ComposerPill( + label: composer.modelLabel ?? 'Model', + maxLabelWidth: 120, + tooltip: 'Choose model', + onTap: enabled ? () => _toggleMenu(controller) : null, + ), ), ); } - void _open(MenuController controller) { - if (controller.isOpen) { - controller.close(); - } else { - controller.open(); - } - } - - Widget _modelItem( - ResolvedModelChoice choice, - ThemeData theme, - ButtonStyle itemStyle, - ) { + Widget _modelItem(ResolvedModelChoice choice, ThemeData theme) { final selected = composer.modelSelector == choice.selector; final label = choice.supported ? choice.label : '${choice.label} · ${choice.reason ?? 'Unavailable'}'; return MenuItemButton( key: ValueKey('model-${choice.selector}'), - style: itemStyle, + style: _composerMenuItemStyle, onPressed: choice.supported ? () => onSelect(choice.selector) : null, leadingIcon: selected ? Icon(Icons.check, size: 18, color: theme.colorScheme.primary) @@ -1495,3 +2295,62 @@ final class _ModelSelectorMenu extends StatelessWidget { ); } } + +/// Informational access pill for the composer controls: summarizes the +/// capabilities the host actually granted. Selection is a no-op — the wire +/// protocol has no capability-mutation command. +final class _AccessSummaryPill extends StatelessWidget { + const _AccessSummaryPill({required this.state}); + + final T4ViewState state; + + /// Display order and labels for the summarized capability groups, keyed by + /// the capability prefix used on the wire (`term.input`, `files.write`, …). + static const Map _groupLabels = { + 'files': 'Files', + 'term': 'Terminal', + 'preview': 'Preview', + 'sessions': 'Sessions', + 'usage': 'Usage', + }; + + static List _options(Set granted) { + final byGroup = >{}; + for (final capability in granted) { + final prefix = capability.split('.').first; + if (!_groupLabels.containsKey(prefix)) continue; + byGroup.putIfAbsent(prefix, () => []).add(capability); + } + return [ + for (final entry in _groupLabels.entries) + if (byGroup[entry.key] case final capabilities?) + AccessModeOption( + id: entry.key, + label: entry.value, + detail: (capabilities..sort()).join(', '), + selected: true, + ), + ]; + } + + @override + Widget build(BuildContext context) { + final granted = state.grantedCapabilities; + final canWriteFiles = granted.contains('files.write'); + final label = canWriteFiles && granted.contains('term.input') + ? 'Full access' + : canWriteFiles + ? 'Write access' + : 'Read only'; + return Tooltip( + message: 'Granted permissions', + child: AccessModeSelector( + label: label, + options: _options(granted), + onSelected: (_) {}, + enabled: state.connectionPhase == ConnectionPhase.ready, + readOnly: true, + ), + ); + } +} diff --git a/apps/flutter/lib/src/ui/developer_surfaces.dart b/apps/flutter/lib/src/ui/developer_surfaces.dart index 35a54cb6..74e05929 100644 --- a/apps/flutter/lib/src/ui/developer_surfaces.dart +++ b/apps/flutter/lib/src/ui/developer_surfaces.dart @@ -22,29 +22,14 @@ final class _DeveloperSurfacesPane extends StatefulWidget { final class _DeveloperSurfacesPaneState extends State<_DeveloperSurfacesPane> with SingleTickerProviderStateMixin { late final TabController _tabs; - late List _activitySnapshot; - final TextEditingController _launchUrlController = TextEditingController(); - final TextEditingController _navigationUrlController = - TextEditingController(); - final FocusNode _navigationUrlFocus = FocusNode(); - final TextEditingController _fileEditorController = TextEditingController(); - - String? _activityCategory; - String? _busyAction; - String? _actionError; - String? _selectedFilePath; - String _directoryPath = ''; - String? _editedFilePath; - bool _fileDirty = false; - bool _activityPaused = false; - - bool get _connected => widget.state.connectionPhase == ConnectionPhase.ready; - - bool _hasCapability(String capability) => - widget.state.grantedCapabilities.contains(capability); + late final DeveloperPanelActionController _panelActions; + late final ActivityPanelController _activity; + late final FilesPanelController _files; + late final PreviewPanelController _preview; bool get _busy => - _busyAction != null || widget.state.developerOperationPending; + _panelActions.busyAction != null || + widget.state.developerOperationPending; @override void initState() { @@ -54,16 +39,17 @@ final class _DeveloperSurfacesPaneState extends State<_DeveloperSurfacesPane> initialIndex: _boundedDeveloperTab(widget.initialTab), vsync: this, ); - _activitySnapshot = List.of(widget.state.activities); - final workspace = widget.state.fileWorkspace; - if (workspace.content != null && workspace.path.isNotEmpty) { - _selectedFilePath = workspace.path; - _directoryPath = _parentPath(workspace.path); - } else { - _directoryPath = workspace.path; - } - _syncPreviewAddress(force: true); - _syncFileEditor(force: true); + _panelActions = DeveloperPanelActionController() + ..addListener(_onPanelChanged); + _activity = ActivityPanelController(widget.state) + ..addListener(_onPanelChanged); + _files = FilesPanelController(widget.state)..addListener(_onPanelChanged); + _preview = PreviewPanelController(widget.state) + ..addListener(_onPanelChanged); + } + + void _onPanelChanged() { + if (mounted) setState(() {}); } @override @@ -73,345 +59,650 @@ final class _DeveloperSurfacesPaneState extends State<_DeveloperSurfacesPane> _tabs.index != widget.initialTab) { _tabs.animateTo(_boundedDeveloperTab(widget.initialTab)); } - if (!_activityPaused && - !identical(oldWidget.state.activities, widget.state.activities)) { - _activitySnapshot = List.of(widget.state.activities); - } - final oldPreview = oldWidget.state.activePreview; - final preview = widget.state.activePreview; - if (oldPreview?.previewId != preview?.previewId || - oldPreview?.url != preview?.url) { - _syncPreviewAddress(); - } - final oldWorkspace = oldWidget.state.fileWorkspace; - final workspace = widget.state.fileWorkspace; - if (oldWorkspace.path != workspace.path || - oldWorkspace.content != workspace.content) { - _syncFileEditor(); - } + _activity.syncFromState(oldWidget.state, widget.state); + _preview.syncFromState(oldWidget.state, widget.state); + _files.syncFromState(oldWidget.state, widget.state); } @override void dispose() { _tabs.dispose(); - _launchUrlController.dispose(); - _navigationUrlController.dispose(); - _navigationUrlFocus.dispose(); - _fileEditorController.dispose(); + _panelActions.dispose(); + _activity.dispose(); + _files.dispose(); + _preview.dispose(); super.dispose(); } - void _syncPreviewAddress({bool force = false}) { - if (!force && _navigationUrlFocus.hasFocus) return; - final url = widget.state.activePreview?.url ?? ''; - _navigationUrlController.value = TextEditingValue( - text: url, - selection: TextSelection.collapsed(offset: url.length), - ); - } - - void _syncFileEditor({bool force = false}) { - final workspace = widget.state.fileWorkspace; - final content = workspace.content; - if (content == null || workspace.path.isEmpty) return; - if (!force && _fileDirty && _editedFilePath == workspace.path) return; - _editedFilePath = workspace.path; - _fileDirty = false; - _fileEditorController.value = TextEditingValue( - text: content, - selection: TextSelection.collapsed(offset: content.length), - ); - } - - Future _confirmDiscardFileChanges() async { - if (!_fileDirty) return true; - final discard = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Discard unsaved changes?'), - content: Text( - 'Your edits to ${_editedFilePath ?? 'this file'} have not been saved.', - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context, false), - child: const Text('Keep editing'), + @override + Widget build(BuildContext context) { + return Column( + children: [ + if (widget.showHeader) _DeveloperSurfacesHeader(onDone: widget.onDone), + if (_panelActions.actionError case final error?) + _DeveloperErrorBanner( + message: error, + onDismiss: _panelActions.clearError, ), - FilledButton( - onPressed: () => Navigator.pop(context, true), - child: const Text('Discard'), + if (_busy) + const LinearProgressIndicator( + minHeight: _T4Size.thinStroke, + semanticsLabel: 'Developer operation in progress', ), - ], - ), + TabBar( + controller: _tabs, + isScrollable: true, + tabAlignment: TabAlignment.start, + tabs: const [ + Tab(icon: Icon(Icons.receipt_long_outlined), text: 'Activity'), + Tab(icon: Icon(Icons.folder_outlined), text: 'Files'), + Tab(icon: Icon(Icons.difference_outlined), text: 'Review'), + Tab(icon: Icon(Icons.terminal), text: 'Terminal'), + Tab(icon: Icon(Icons.preview_outlined), text: 'Preview'), + ], + ), + Expanded( + child: TabBarView( + controller: _tabs, + children: [ + ActivityPanelBody( + state: widget.state, + actions: widget.actions, + controller: _activity, + actionController: _panelActions, + ), + FilesPanelBody( + state: widget.state, + actions: widget.actions, + controller: _files, + actionController: _panelActions, + ), + ReviewPanelBody( + state: widget.state, + actions: widget.actions, + selectedFilePath: _files.selectedFilePath, + onOpenFiles: () => _tabs.animateTo(1), + actionController: _panelActions, + ), + TerminalPanelBody(state: widget.state, actions: widget.actions), + PreviewPanelBody( + state: widget.state, + actions: widget.actions, + controller: _preview, + actionController: _panelActions, + ), + ], + ), + ), + ], ); - if (discard == true && mounted) { - setState(() => _fileDirty = false); - } - return discard == true; } +} - void _onFileChanged(String content) { - final workspace = widget.state.fileWorkspace; - final dirty = - _editedFilePath == workspace.path && content != workspace.content; - if (dirty != _fileDirty) setState(() => _fileDirty = dirty); +/// Base for developer panel state holders: setState-style [mutate] with safe +/// change notification after disposal. +abstract base class DeveloperPanelController extends ChangeNotifier { + bool _panelControllerDisposed = false; + + void mutate(VoidCallback fn) { + fn(); + if (!_panelControllerDisposed) notifyListeners(); } - Future _saveFile() async { - final path = _editedFilePath; - if (path == null || !_fileDirty) return; - await _runAction('files-write', 'Could not save the file.', () async { - await widget.actions.writeFile(path, _fileEditorController.text); - if (mounted) setState(() => _fileDirty = false); - }); + @override + void dispose() { + _panelControllerDisposed = true; + super.dispose(); } +} - Future _runAction( - String key, - String failureMessage, - Future Function() action, - ) async { - if (_busy) return; - setState(() { +/// Shared busy/error state for developer panel operations. The tabbed +/// takeover owns one instance across all tabs; an embedded panel body creates +/// its own when none is supplied. +final class DeveloperPanelActionController extends DeveloperPanelController { + String? _busyAction; + String? _actionError; + + String? get busyAction => _busyAction; + String? get actionError => _actionError; + + void clearError() => mutate(() => _actionError = null); + + Future run( + BuildContext context, { + required bool externallyPending, + required String key, + required String failureMessage, + required Future Function() action, + }) async { + if (_busyAction != null || externallyPending) return; + final messenger = ScaffoldMessenger.maybeOf(context); + mutate(() { _busyAction = key; _actionError = null; }); try { await action(); } on Object catch (error) { - if (!mounted) return; + if (_panelControllerDisposed) return; final detail = error.toString().replaceFirst('Bad state: ', '').trim(); - setState(() { + mutate(() { _actionError = detail.isEmpty ? failureMessage : detail; }); - ScaffoldMessenger.of(context) - ..hideCurrentSnackBar() + messenger + ?..hideCurrentSnackBar() ..showSnackBar(SnackBar(content: Text(failureMessage))); } finally { - if (mounted) setState(() => _busyAction = null); + if (!_panelControllerDisposed) { + mutate(() => _busyAction = null); + } } } +} - void _toggleActivityPause() { - setState(() { - _activityPaused = !_activityPaused; - if (!_activityPaused) { - _activitySnapshot = List.of(widget.state.activities); - } - }); +/// Per-tab state for the Activity panel. +final class ActivityPanelController extends DeveloperPanelController { + ActivityPanelController(T4ViewState state) + : snapshot = List.of(state.activities); + + List snapshot; + String? category; + bool paused = false; + + void selectCategory(String? value) => mutate(() => category = value); + + void togglePause(T4ViewState state) => mutate(() { + paused = !paused; + if (!paused) { + snapshot = List.of(state.activities); + } + }); + + /// Refreshes the snapshot on state updates. Intentionally silent: callers + /// invoke this from didUpdateWidget and rebuild right after. + void syncFromState(T4ViewState oldState, T4ViewState newState) { + if (!paused && !identical(oldState.activities, newState.activities)) { + snapshot = List.of(newState.activities); + } } +} - Future _listDirectory(String path) async { - if (!await _confirmDiscardFileChanges() || !mounted) return; - setState(() { - _directoryPath = path; - _selectedFilePath = null; - _editedFilePath = null; - _fileEditorController.clear(); - }); - await _runAction( - 'files-list', - 'Could not list that directory.', - () => widget.actions.listFiles(path), +/// Per-tab state for the Files panel; also feeds the Review panel's selected +/// file when both are driven by the tabbed takeover. +final class FilesPanelController extends DeveloperPanelController { + FilesPanelController(T4ViewState state) { + final workspace = state.fileWorkspace; + if (workspace.content != null && workspace.path.isNotEmpty) { + selectedFilePath = workspace.path; + directoryPath = _parentPath(workspace.path); + } else { + directoryPath = workspace.path; + } + syncEditor(state, force: true); + } + + final TextEditingController editor = TextEditingController(); + String? selectedFilePath; + String directoryPath = ''; + String? editedFilePath; + bool dirty = false; + + void syncEditor(T4ViewState state, {bool force = false}) { + final workspace = state.fileWorkspace; + final content = workspace.content; + if (content == null || workspace.path.isEmpty) return; + selectedFilePath = workspace.path; + if (!force && dirty && editedFilePath == workspace.path) return; + editedFilePath = workspace.path; + dirty = false; + editor.value = TextEditingValue( + text: content, + selection: TextSelection.collapsed(offset: content.length), ); } - Future _selectFile(String path) async { - if (path != _editedFilePath && - (!await _confirmDiscardFileChanges() || !mounted)) { - return; + /// Mirrors the editor sync previously done in the pane's didUpdateWidget. + void syncFromState(T4ViewState oldState, T4ViewState newState) { + final oldWorkspace = oldState.fileWorkspace; + final workspace = newState.fileWorkspace; + if (oldWorkspace.path != workspace.path || + oldWorkspace.content != workspace.content) { + syncEditor(newState); } - setState(() => _selectedFilePath = path); - await _runAction( - 'files-read', - 'Could not read that file.', - () => widget.actions.readFile(path), - ); } - Future _launchPreview() async { - final url = _launchUrlController.text.trim(); - if (url.isEmpty) return; - await _runAction( - 'preview-launch', - 'Could not launch the preview.', - () async { - await widget.actions.launchPreview(url); - _launchUrlController.clear(); - }, - ); + @override + void dispose() { + editor.dispose(); + super.dispose(); } +} - Future _navigatePreview(PreviewWorkspaceState preview) async { - final url = _navigationUrlController.text.trim(); - if (url.isEmpty || url == preview.url) return; - await _runAction( - 'preview-navigate', - 'Could not navigate the preview.', - () => widget.actions.navigatePreview(preview.previewId, url), - ); +/// Per-tab state for the Preview panel. +final class PreviewPanelController extends DeveloperPanelController { + PreviewPanelController(T4ViewState state) { + syncAddress(state, force: true); } - Future _previewAction(PreviewWorkspaceState preview, String action) => - _runAction( - 'preview-$action', - 'Could not $action the preview.', - () => widget.actions.runPreviewAction(preview.previewId, action), - ); + final TextEditingController launchUrl = TextEditingController(); + final TextEditingController navigationUrl = TextEditingController(); + final FocusNode navigationFocus = FocusNode(); - Future _openPreviewInteraction( - PreviewWorkspaceState preview, { - required bool allowInput, - required bool allowHandoff, - }) async { - final request = await showModalBottomSheet<_PreviewInteractionRequest>( - context: context, - isScrollControlled: true, - useSafeArea: true, - builder: (context) => _PreviewInteractionSheet( - allowInput: allowInput, - allowHandoff: allowHandoff, - ), - ); - if (request == null || !mounted) return; - await _runAction( - 'preview-${request.action}', - 'Could not ${request.action} in the preview.', - () => widget.actions.runPreviewInteraction( - preview.previewId, - request.action, - request.args, - ), + void syncAddress(T4ViewState state, {bool force = false}) { + if (!force && navigationFocus.hasFocus) return; + final url = state.activePreview?.url ?? ''; + navigationUrl.value = TextEditingValue( + text: url, + selection: TextSelection.collapsed(offset: url.length), ); } + /// Mirrors the address sync previously done in the pane's didUpdateWidget. + void syncFromState(T4ViewState oldState, T4ViewState newState) { + final oldPreview = oldState.activePreview; + final preview = newState.activePreview; + if (oldPreview?.previewId != preview?.previewId || + oldPreview?.url != preview?.url) { + syncAddress(newState); + } + } + + @override + void dispose() { + launchUrl.dispose(); + navigationUrl.dispose(); + navigationFocus.dispose(); + super.dispose(); + } +} + +/// Error banner and progress indicator wrapper used by panel bodies that own +/// their action controller (embedded use). The tabbed takeover renders these +/// itself, above the tab strip. +final class _PanelActionChrome extends StatelessWidget { + const _PanelActionChrome({ + required this.controller, + required this.busy, + required this.child, + }); + + final DeveloperPanelActionController controller; + final bool busy; + final Widget child; + @override Widget build(BuildContext context) { return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - if (widget.showHeader) _DeveloperSurfacesHeader(onDone: widget.onDone), - if (_actionError case final error?) + if (controller.actionError case final error?) _DeveloperErrorBanner( message: error, - onDismiss: () => setState(() => _actionError = null), + onDismiss: controller.clearError, ), - if (_busy) + if (busy) const LinearProgressIndicator( minHeight: _T4Size.thinStroke, semanticsLabel: 'Developer operation in progress', ), - TabBar( - controller: _tabs, - isScrollable: true, - tabAlignment: TabAlignment.start, - tabs: const [ - Tab(icon: Icon(Icons.receipt_long_outlined), text: 'Activity'), - Tab(icon: Icon(Icons.folder_outlined), text: 'Files'), - Tab(icon: Icon(Icons.difference_outlined), text: 'Review'), - Tab(icon: Icon(Icons.terminal), text: 'Terminal'), - Tab(icon: Icon(Icons.preview_outlined), text: 'Preview'), - ], + Expanded(child: child), + ], + ); + } +} + +/// Embeddable body of the developer Activity tab. +final class ActivityPanelBody extends StatefulWidget { + const ActivityPanelBody({ + required this.state, + required this.actions, + this.controller, + this.actionController, + super.key, + }); + + final T4ViewState state; + final T4Actions actions; + + /// Optional external per-tab state; the body owns its own when omitted. + final ActivityPanelController? controller; + + /// Optional shared busy/error state; the body owns its own (and renders its + /// own error banner and progress indicator) when omitted. + final DeveloperPanelActionController? actionController; + + @override + State createState() => _ActivityPanelBodyState(); +} + +final class _ActivityPanelBodyState extends State { + late ActivityPanelController _activity; + late DeveloperPanelActionController _action; + + bool get _ownsController => widget.controller == null; + bool get _ownsActionController => widget.actionController == null; + bool get _connected => widget.state.connectionPhase == ConnectionPhase.ready; + bool get _busy => + _action.busyAction != null || widget.state.developerOperationPending; + + bool _hasCapability(String capability) => + widget.state.grantedCapabilities.contains(capability); + + @override + void initState() { + super.initState(); + _activity = widget.controller ?? ActivityPanelController(widget.state); + _action = widget.actionController ?? DeveloperPanelActionController(); + _activity.addListener(_onPanelChanged); + _action.addListener(_onPanelChanged); + } + + @override + void didUpdateWidget(covariant ActivityPanelBody oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + _activity.removeListener(_onPanelChanged); + if (oldWidget.controller == null) _activity.dispose(); + _activity = widget.controller ?? ActivityPanelController(widget.state); + _activity.addListener(_onPanelChanged); + } + if (oldWidget.actionController != widget.actionController) { + _action.removeListener(_onPanelChanged); + if (oldWidget.actionController == null) _action.dispose(); + _action = widget.actionController ?? DeveloperPanelActionController(); + _action.addListener(_onPanelChanged); + } + if (_ownsController) { + _activity.syncFromState(oldWidget.state, widget.state); + } + } + + @override + void dispose() { + _activity.removeListener(_onPanelChanged); + _action.removeListener(_onPanelChanged); + if (_ownsController) _activity.dispose(); + if (_ownsActionController) _action.dispose(); + super.dispose(); + } + + void _onPanelChanged() { + if (mounted) setState(() {}); + } + + Future _runAction( + String key, + String failureMessage, + Future Function() action, + ) => _action.run( + context, + externallyPending: widget.state.developerOperationPending, + key: key, + failureMessage: failureMessage, + action: action, + ); + + @override + Widget build(BuildContext context) { + final content = _buildContent(context); + if (!_ownsActionController) return content; + return _PanelActionChrome(controller: _action, busy: _busy, child: content); + } + + Widget _buildContent(BuildContext context) { + if (!_connected && _activity.snapshot.isEmpty) { + return const _DeveloperUnavailable( + icon: Icons.cloud_off_outlined, + title: 'Activity is offline', + message: 'Connect to a host to inspect protocol activity.', + ); + } + if (!_hasCapability('audit.read')) { + return const _DeveloperUnavailable( + icon: Icons.lock_outline, + title: 'Activity is unavailable', + message: 'The paired host did not grant the audit.read capability.', + ); + } + + final categories = + _activity.snapshot + .map((activity) => activity.category.trim()) + .where((category) => category.isNotEmpty) + .toSet() + .toList(growable: false) + ..sort(); + if (_activity.category != null && + !categories.contains(_activity.category)) { + _activity.category = null; + } + final activities = + _activity.snapshot + .where( + (activity) => + _activity.category == null || + activity.category == _activity.category, + ) + .toList(growable: false) + ..sort((a, b) => b.at.compareTo(a.at)); + + return Column( + children: [ + _ActivityToolbar( + categories: categories, + selectedCategory: _activity.category, + paused: _activity.paused, + busy: _busy, + onSelectCategory: _activity.selectCategory, + onTogglePause: () => _activity.togglePause(widget.state), + onRefresh: !_connected + ? null + : () => _runAction( + 'activity-refresh', + 'Could not refresh activity.', + widget.actions.refreshActivity, + ), ), - Expanded( - child: TabBarView( - controller: _tabs, - children: [ - _buildActivity(), - _buildFiles(), - _buildReview(), - _TerminalPane(state: widget.state, actions: widget.actions), - _buildPreview(), - ], + if (_activity.paused) + const _DeveloperNotice( + icon: Icons.pause_circle_outline, + message: 'Activity is paused. New rows are held until you resume.', ), + Expanded( + child: activities.isEmpty + ? _DeveloperUnavailable( + icon: Icons.filter_alt_off_outlined, + title: _activity.snapshot.isEmpty + ? 'No activity yet' + : 'No matching activity', + message: _activity.snapshot.isEmpty + ? 'Refresh to request the latest host activity.' + : 'Choose a different category filter.', + ) + : ListView.separated( + padding: const EdgeInsets.all(_T4Space.md), + itemCount: activities.length, + separatorBuilder: (_, _) => + const SizedBox(height: _T4Space.xs), + itemBuilder: (context, index) => + _ActivityRow(activity: activities[index]), + ), ), ], ); } +} + +/// Embeddable body of the developer Files tab. +final class FilesPanelBody extends StatefulWidget { + const FilesPanelBody({ + required this.state, + required this.actions, + this.controller, + this.actionController, + super.key, + }); + + final T4ViewState state; + final T4Actions actions; + + /// Optional external per-tab state; the body owns its own when omitted. + final FilesPanelController? controller; + + /// Optional shared busy/error state; the body owns its own (and renders its + /// own error banner and progress indicator) when omitted. + final DeveloperPanelActionController? actionController; + + @override + State createState() => _FilesPanelBodyState(); +} + +final class _FilesPanelBodyState extends State { + late FilesPanelController _files; + late DeveloperPanelActionController _action; + + bool get _ownsController => widget.controller == null; + bool get _ownsActionController => widget.actionController == null; + bool get _connected => widget.state.connectionPhase == ConnectionPhase.ready; + bool get _busy => + _action.busyAction != null || widget.state.developerOperationPending; + + bool _hasCapability(String capability) => + widget.state.grantedCapabilities.contains(capability); + + @override + void initState() { + super.initState(); + _files = widget.controller ?? FilesPanelController(widget.state); + _action = widget.actionController ?? DeveloperPanelActionController(); + _files.addListener(_onPanelChanged); + _action.addListener(_onPanelChanged); + } + + @override + void didUpdateWidget(covariant FilesPanelBody oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + _files.removeListener(_onPanelChanged); + if (oldWidget.controller == null) _files.dispose(); + _files = widget.controller ?? FilesPanelController(widget.state); + _files.addListener(_onPanelChanged); + } + if (oldWidget.actionController != widget.actionController) { + _action.removeListener(_onPanelChanged); + if (oldWidget.actionController == null) _action.dispose(); + _action = widget.actionController ?? DeveloperPanelActionController(); + _action.addListener(_onPanelChanged); + } + if (_ownsController) { + _files.syncFromState(oldWidget.state, widget.state); + } + } + + @override + void dispose() { + _files.removeListener(_onPanelChanged); + _action.removeListener(_onPanelChanged); + if (_ownsController) _files.dispose(); + if (_ownsActionController) _action.dispose(); + super.dispose(); + } + + void _onPanelChanged() { + if (mounted) setState(() {}); + } + + Future _runAction( + String key, + String failureMessage, + Future Function() action, + ) => _action.run( + context, + externallyPending: widget.state.developerOperationPending, + key: key, + failureMessage: failureMessage, + action: action, + ); + + Future _confirmDiscardFileChanges() async { + if (!_files.dirty) return true; + final discard = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Discard unsaved changes?'), + content: Text( + 'Your edits to ${_files.editedFilePath ?? 'this file'} have not been saved.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Keep editing'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Discard'), + ), + ], + ), + ); + if (discard == true && mounted) { + _files.mutate(() => _files.dirty = false); + } + return discard == true; + } - Widget _buildActivity() { - if (!_connected && _activitySnapshot.isEmpty) { - return const _DeveloperUnavailable( - icon: Icons.cloud_off_outlined, - title: 'Activity is offline', - message: 'Connect to a host to inspect protocol activity.', - ); - } - if (!_hasCapability('audit.read')) { - return const _DeveloperUnavailable( - icon: Icons.lock_outline, - title: 'Activity is unavailable', - message: 'The paired host did not grant the audit.read capability.', - ); - } + void _onFileChanged(String content) { + final workspace = widget.state.fileWorkspace; + final dirty = + _files.editedFilePath == workspace.path && content != workspace.content; + if (dirty != _files.dirty) _files.mutate(() => _files.dirty = dirty); + } - final categories = - _activitySnapshot - .map((activity) => activity.category.trim()) - .where((category) => category.isNotEmpty) - .toSet() - .toList(growable: false) - ..sort(); - if (_activityCategory != null && !categories.contains(_activityCategory)) { - _activityCategory = null; - } - final activities = - _activitySnapshot - .where( - (activity) => - _activityCategory == null || - activity.category == _activityCategory, - ) - .toList(growable: false) - ..sort((a, b) => b.at.compareTo(a.at)); + Future _saveFile() async { + final path = _files.editedFilePath; + if (path == null || !_files.dirty) return; + await _runAction('files-write', 'Could not save the file.', () async { + await widget.actions.writeFile(path, _files.editor.text); + _files.mutate(() => _files.dirty = false); + }); + } - return Column( - children: [ - _ActivityToolbar( - categories: categories, - selectedCategory: _activityCategory, - paused: _activityPaused, - busy: _busy, - onSelectCategory: (category) => - setState(() => _activityCategory = category), - onTogglePause: _toggleActivityPause, - onRefresh: !_connected - ? null - : () => _runAction( - 'activity-refresh', - 'Could not refresh activity.', - widget.actions.refreshActivity, - ), - ), - if (_activityPaused) - const _DeveloperNotice( - icon: Icons.pause_circle_outline, - message: 'Activity is paused. New rows are held until you resume.', - ), - Expanded( - child: activities.isEmpty - ? _DeveloperUnavailable( - icon: Icons.filter_alt_off_outlined, - title: _activitySnapshot.isEmpty - ? 'No activity yet' - : 'No matching activity', - message: _activitySnapshot.isEmpty - ? 'Refresh to request the latest host activity.' - : 'Choose a different category filter.', - ) - : ListView.separated( - padding: const EdgeInsets.all(_T4Space.md), - itemCount: activities.length, - separatorBuilder: (_, _) => - const SizedBox(height: _T4Space.xs), - itemBuilder: (context, index) => - _ActivityRow(activity: activities[index]), - ), - ), - ], + Future _listDirectory(String path) async { + if (!await _confirmDiscardFileChanges() || !mounted) return; + _files.mutate(() { + _files.directoryPath = path; + _files.selectedFilePath = null; + _files.editedFilePath = null; + _files.editor.clear(); + }); + await _runAction( + 'files-list', + 'Could not list that directory.', + () => widget.actions.listFiles(path), + ); + } + + Future _selectFile(String path) async { + if (path != _files.editedFilePath && + (!await _confirmDiscardFileChanges() || !mounted)) { + return; + } + _files.mutate(() => _files.selectedFilePath = path); + await _runAction( + 'files-read', + 'Could not read that file.', + () => widget.actions.readFile(path), ); } - Widget _buildFiles() { + @override + Widget build(BuildContext context) { + final content = _buildContent(context); + if (!_ownsActionController) return content; + return _PanelActionChrome(controller: _action, busy: _busy, child: content); + } + + Widget _buildContent(BuildContext context) { final canList = _hasCapability('files.list'); final canRead = _hasCapability('files.read'); if (!_connected && widget.state.fileWorkspace.entries.isEmpty) { @@ -432,22 +723,22 @@ final class _DeveloperSurfacesPaneState extends State<_DeveloperSurfacesPane> final workspace = widget.state.fileWorkspace; final filePath = - _selectedFilePath ?? + _files.selectedFilePath ?? (workspace.content == null ? null : workspace.path); final content = filePath == workspace.path ? workspace.content : null; return LayoutBuilder( builder: (context, constraints) { final list = _FileBrowser( - directoryPath: _directoryPath, + directoryPath: _files.directoryPath, entries: workspace.entries, selectedPath: filePath, busy: _busy || workspace.loading, error: workspace.error, onRefresh: _connected && !_busy - ? () => _listDirectory(_directoryPath) + ? () => _listDirectory(_files.directoryPath) : null, - onParent: _connected && !_busy && _directoryPath.isNotEmpty - ? () => _listDirectory(_parentPath(_directoryPath)) + onParent: _connected && !_busy && _files.directoryPath.isNotEmpty + ? () => _listDirectory(_parentPath(_files.directoryPath)) : null, onOpenDirectory: _connected && !_busy ? _listDirectory : null, onOpenFile: _connected && !_busy ? _selectFile : null, @@ -455,17 +746,16 @@ final class _DeveloperSurfacesPaneState extends State<_DeveloperSurfacesPane> final viewer = _SourceViewer( path: filePath, content: content, - loading: _busyAction == 'files-read', - controller: _fileEditorController, + loading: _action.busyAction == 'files-read', + controller: _files.editor, editable: _hasCapability('files.write') && _connected && !_busy, - dirty: _fileDirty, + dirty: _files.dirty, onChanged: _onFileChanged, - onSave: _fileDirty && !_busy ? _saveFile : null, - onDiscard: _fileDirty - ? () => setState(() { - _fileEditorController.text = - widget.state.fileWorkspace.content ?? ''; - _fileDirty = false; + onSave: _files.dirty && !_busy ? _saveFile : null, + onDiscard: _files.dirty + ? () => _files.mutate(() { + _files.editor.text = widget.state.fileWorkspace.content ?? ''; + _files.dirty = false; }) : null, ); @@ -488,12 +778,102 @@ final class _DeveloperSurfacesPaneState extends State<_DeveloperSurfacesPane> }, ); } +} + +/// Embeddable body of the developer Review tab. +final class ReviewPanelBody extends StatefulWidget { + const ReviewPanelBody({ + required this.state, + required this.actions, + this.selectedFilePath, + this.onOpenFiles, + this.actionController, + super.key, + }); + + final T4ViewState state; + final T4Actions actions; + + /// Externally selected file (from the Files panel); falls back to the + /// workspace's loaded file when omitted. + final String? selectedFilePath; + + /// Invoked by the "Open files" affordance; the affordance is disabled when + /// omitted. + final VoidCallback? onOpenFiles; + + /// Optional shared busy/error state; the body owns its own (and renders its + /// own error banner and progress indicator) when omitted. + final DeveloperPanelActionController? actionController; + + @override + State createState() => _ReviewPanelBodyState(); +} + +final class _ReviewPanelBodyState extends State { + late DeveloperPanelActionController _action; + + bool get _ownsActionController => widget.actionController == null; + bool get _connected => widget.state.connectionPhase == ConnectionPhase.ready; + bool get _busy => + _action.busyAction != null || widget.state.developerOperationPending; + + bool _hasCapability(String capability) => + widget.state.grantedCapabilities.contains(capability); + + @override + void initState() { + super.initState(); + _action = widget.actionController ?? DeveloperPanelActionController(); + _action.addListener(_onPanelChanged); + } + + @override + void didUpdateWidget(covariant ReviewPanelBody oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.actionController != widget.actionController) { + _action.removeListener(_onPanelChanged); + if (oldWidget.actionController == null) _action.dispose(); + _action = widget.actionController ?? DeveloperPanelActionController(); + _action.addListener(_onPanelChanged); + } + } + + @override + void dispose() { + _action.removeListener(_onPanelChanged); + if (_ownsActionController) _action.dispose(); + super.dispose(); + } + + void _onPanelChanged() { + if (mounted) setState(() {}); + } + + Future _runAction( + String key, + String failureMessage, + Future Function() action, + ) => _action.run( + context, + externallyPending: widget.state.developerOperationPending, + key: key, + failureMessage: failureMessage, + action: action, + ); + + @override + Widget build(BuildContext context) { + final content = _buildContent(context); + if (!_ownsActionController) return content; + return _PanelActionChrome(controller: _action, busy: _busy, child: content); + } - Widget _buildReview() { + Widget _buildContent(BuildContext context) { final workspace = widget.state.fileWorkspace; final reviews = widget.state.reviews; final selectedPath = - _selectedFilePath ?? + widget.selectedFilePath ?? (workspace.content == null ? null : workspace.path); if (!_connected && workspace.diff == null && reviews.isEmpty) { return const _DeveloperUnavailable( @@ -543,7 +923,7 @@ final class _DeveloperSurfacesPaneState extends State<_DeveloperSurfacesPane> : 'No file diff selected', message: 'Select a file in Files to inspect its current diff.', action: TextButton.icon( - onPressed: () => _tabs.animateTo(1), + onPressed: widget.onOpenFiles, icon: const Icon(Icons.folder_open_outlined), label: const Text('Open files'), ), @@ -610,8 +990,179 @@ final class _DeveloperSurfacesPaneState extends State<_DeveloperSurfacesPane> ], ); } +} + +/// Embeddable body of the developer Terminal tab. +final class TerminalPanelBody extends StatelessWidget { + const TerminalPanelBody({ + required this.state, + required this.actions, + super.key, + }); + + final T4ViewState state; + final T4Actions actions; + + @override + Widget build(BuildContext context) => + _TerminalPane(state: state, actions: actions); +} + +/// Embeddable body of the developer Preview tab. +final class PreviewPanelBody extends StatefulWidget { + const PreviewPanelBody({ + required this.state, + required this.actions, + this.controller, + this.actionController, + super.key, + }); + + final T4ViewState state; + final T4Actions actions; + + /// Optional external per-tab state; the body owns its own when omitted. + final PreviewPanelController? controller; + + /// Optional shared busy/error state; the body owns its own (and renders its + /// own error banner and progress indicator) when omitted. + final DeveloperPanelActionController? actionController; + + @override + State createState() => _PreviewPanelBodyState(); +} + +final class _PreviewPanelBodyState extends State { + late PreviewPanelController _preview; + late DeveloperPanelActionController _action; + + bool get _ownsController => widget.controller == null; + bool get _ownsActionController => widget.actionController == null; + bool get _connected => widget.state.connectionPhase == ConnectionPhase.ready; + bool get _busy => + _action.busyAction != null || widget.state.developerOperationPending; + + bool _hasCapability(String capability) => + widget.state.grantedCapabilities.contains(capability); + + @override + void initState() { + super.initState(); + _preview = widget.controller ?? PreviewPanelController(widget.state); + _action = widget.actionController ?? DeveloperPanelActionController(); + _preview.addListener(_onPanelChanged); + _action.addListener(_onPanelChanged); + } + + @override + void didUpdateWidget(covariant PreviewPanelBody oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + _preview.removeListener(_onPanelChanged); + if (oldWidget.controller == null) _preview.dispose(); + _preview = widget.controller ?? PreviewPanelController(widget.state); + _preview.addListener(_onPanelChanged); + } + if (oldWidget.actionController != widget.actionController) { + _action.removeListener(_onPanelChanged); + if (oldWidget.actionController == null) _action.dispose(); + _action = widget.actionController ?? DeveloperPanelActionController(); + _action.addListener(_onPanelChanged); + } + if (_ownsController) { + _preview.syncFromState(oldWidget.state, widget.state); + } + } + + @override + void dispose() { + _preview.removeListener(_onPanelChanged); + _action.removeListener(_onPanelChanged); + if (_ownsController) _preview.dispose(); + if (_ownsActionController) _action.dispose(); + super.dispose(); + } + + void _onPanelChanged() { + if (mounted) setState(() {}); + } + + Future _runAction( + String key, + String failureMessage, + Future Function() action, + ) => _action.run( + context, + externallyPending: widget.state.developerOperationPending, + key: key, + failureMessage: failureMessage, + action: action, + ); + + Future _launchPreview() async { + final url = _preview.launchUrl.text.trim(); + if (url.isEmpty) return; + await _runAction( + 'preview-launch', + 'Could not launch the preview.', + () async { + await widget.actions.launchPreview(url); + _preview.launchUrl.clear(); + }, + ); + } + + Future _navigatePreview(PreviewWorkspaceState preview) async { + final url = _preview.navigationUrl.text.trim(); + if (url.isEmpty || url == preview.url) return; + await _runAction( + 'preview-navigate', + 'Could not navigate the preview.', + () => widget.actions.navigatePreview(preview.previewId, url), + ); + } + + Future _previewAction(PreviewWorkspaceState preview, String action) => + _runAction( + 'preview-$action', + 'Could not $action the preview.', + () => widget.actions.runPreviewAction(preview.previewId, action), + ); + + Future _openPreviewInteraction( + PreviewWorkspaceState preview, { + required bool allowInput, + required bool allowHandoff, + }) async { + final request = await showModalBottomSheet<_PreviewInteractionRequest>( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (context) => _PreviewInteractionSheet( + allowInput: allowInput, + allowHandoff: allowHandoff, + ), + ); + if (request == null || !mounted) return; + await _runAction( + 'preview-${request.action}', + 'Could not ${request.action} in the preview.', + () => widget.actions.runPreviewInteraction( + preview.previewId, + request.action, + request.args, + ), + ); + } + + @override + Widget build(BuildContext context) { + final content = _buildContent(context); + if (!_ownsActionController) return content; + return _PanelActionChrome(controller: _action, busy: _busy, child: content); + } - Widget _buildPreview() { + Widget _buildContent(BuildContext context) { final canRead = _hasCapability('preview.read'); final canControl = _hasCapability('preview.control'); final canInput = _hasCapability('preview.input'); @@ -635,7 +1186,7 @@ final class _DeveloperSurfacesPaneState extends State<_DeveloperSurfacesPane> return Column( children: [ _PreviewLaunchBar( - controller: _launchUrlController, + controller: _preview.launchUrl, enabled: _connected && canControl && !_busy, onLaunch: _launchPreview, ), @@ -676,8 +1227,8 @@ final class _DeveloperSurfacesPaneState extends State<_DeveloperSurfacesPane> children: [ _PreviewNavigationBar( preview: preview, - controller: _navigationUrlController, - focusNode: _navigationUrlFocus, + controller: _preview.navigationUrl, + focusNode: _preview.navigationFocus, controlsEnabled: controlsEnabled, captureEnabled: _connected && canRead && !_busy, interactionEnabled: _connected && (canInput || canControl) && !_busy, diff --git a/apps/flutter/lib/src/ui/session_navigation.dart b/apps/flutter/lib/src/ui/session_navigation.dart index 07170abd..40df6866 100644 --- a/apps/flutter/lib/src/ui/session_navigation.dart +++ b/apps/flutter/lib/src/ui/session_navigation.dart @@ -158,7 +158,7 @@ final class _SessionNavigationState extends State<_SessionNavigation> { widget.mode == _SessionNavigationMode.rail ? 'T4' : 'Navigation', - style: Theme.of(context).textTheme.titleLarge, + style: Theme.of(context).textTheme.titleSmall, ), ), if (widget.onClose case final close?) @@ -170,59 +170,32 @@ final class _SessionNavigationState extends State<_SessionNavigation> { ], ), ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: _T4Space.md), - child: _ConnectionStatus( - phase: widget.state.connectionPhase, - actionPending: widget.connecting || widget.disconnecting, - onConnect: widget.onConnect, - onDisconnect: widget.onDisconnect, - ), - ), - if (activeProfile != null) - Padding( - padding: const EdgeInsets.fromLTRB( - _T4Space.md, - _T4Space.xs, - _T4Space.md, - _T4Space.sm, - ), - child: Text( - activeProfile.label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: scheme.onSurfaceVariant, - ), - ), - ), Padding( padding: const EdgeInsets.fromLTRB( - _T4Space.xs, - _T4Space.sm, - _T4Space.xs, + _T4Space.md, 0, + _T4Space.md, + _T4Space.xxs, ), - child: Semantics( - button: true, - selected: widget.showingHostManager, - label: 'Manage hosts', - child: ListTile( - selected: widget.showingHostManager, - selectedTileColor: scheme.secondaryContainer, - leading: const Icon(Icons.dns_outlined), - title: const Text('Manage hosts'), - trailing: const Icon(Icons.chevron_right), - onTap: widget.onManageHosts, + child: Align( + alignment: Alignment.centerLeft, + child: _HostChip( + phase: widget.state.connectionPhase, + hostLabel: activeProfile?.label ?? 'No host', + actionPending: widget.connecting || widget.disconnecting, + showingHostManager: widget.showingHostManager, + onConnect: widget.onConnect, + onDisconnect: widget.onDisconnect, + onManageHosts: widget.onManageHosts, ), ), ), Padding( padding: const EdgeInsets.fromLTRB( _T4Space.md, - _T4Space.md, - _T4Space.xs, + _T4Space.sm, _T4Space.xs, + 0, ), child: Row( children: [ @@ -314,16 +287,19 @@ final class _SessionNavigationState extends State<_SessionNavigation> { Padding( padding: const EdgeInsets.fromLTRB( _T4Space.sm, - _T4Space.sm, + _T4Space.xs, _T4Space.sm, _T4Space.xxs, ), child: Text( - entry.value.first.projectName, + entry.value.first.projectName.toUpperCase(), maxLines: 1, overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelMedium - ?.copyWith(color: scheme.onSurfaceVariant), + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + color: scheme.onSurfaceVariant, + letterSpacing: 0.6, + ), ), ), for (final session in entry.value) @@ -357,61 +333,118 @@ final class _SessionNavigationState extends State<_SessionNavigation> { } } -final class _ConnectionStatus extends StatelessWidget { - const _ConnectionStatus({ +final class _HostChip extends StatelessWidget { + const _HostChip({ required this.phase, + required this.hostLabel, required this.actionPending, + required this.showingHostManager, required this.onConnect, required this.onDisconnect, + required this.onManageHosts, }); final ConnectionPhase phase; + final String hostLabel; final bool actionPending; + final bool showingHostManager; final Future Function() onConnect; final Future Function() onDisconnect; + final VoidCallback onManageHosts; @override Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - final actionLabel = phase.actionLabel; + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final semantic = T4SemanticColors.of(context); + final dotColor = switch (phase) { + ConnectionPhase.ready => semantic.statusDone, + ConnectionPhase.connecting || + ConnectionPhase.synchronizing || + ConnectionPhase.retrying => semantic.statusWorking, + ConnectionPhase.failed => semantic.statusError, + ConnectionPhase.disconnected => scheme.outline, + }; final action = phase.canDisconnect ? onDisconnect : onConnect; - final active = phase.isActive || actionPending; return Semantics( container: true, label: 'Connection status: ${phase.label}', - child: Row( - children: [ - SizedBox.square( - dimension: _T4Size.indicator, - child: active - ? CircularProgressIndicator( - strokeWidth: _T4Size.thinStroke, - color: scheme.primary, - semanticsLabel: phase.label, - ) - : Icon( - Icons.circle, - size: _T4Space.xs, - color: phase == ConnectionPhase.ready - ? scheme.primary - : scheme.outline, - ), - ), - const SizedBox(width: _T4Space.xs), - Expanded( + child: MenuAnchor( + menuChildren: [ + Padding( + padding: const EdgeInsets.fromLTRB( + _T4Space.sm, + _T4Space.xs, + _T4Space.sm, + _T4Space.xxs, + ), child: Text( - phase.label, - style: Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(color: scheme.onSurfaceVariant), + '${phase.label} · $hostLabel', + style: theme.textTheme.labelSmall?.copyWith( + color: scheme.onSurfaceVariant, + ), ), ), - TextButton( + MenuItemButton( + onPressed: onManageHosts, + leadingIcon: const Icon(Icons.dns_outlined), + child: const Text('Manage hosts'), + ), + MenuItemButton( onPressed: actionPending ? null : () => unawaited(action()), - child: Text(actionPending ? 'Working…' : actionLabel), + style: phase.canDisconnect + ? MenuItemButton.styleFrom(foregroundColor: scheme.error) + : null, + child: Text(actionPending ? 'Working…' : phase.actionLabel), ), ], + builder: (context, controller, _) => Tooltip( + message: 'Host menu', + child: Semantics( + button: true, + child: InkWell( + borderRadius: BorderRadius.circular(_T4Radius.lg), + onTap: () => + controller.isOpen ? controller.close() : controller.open(), + child: Ink( + padding: const EdgeInsets.symmetric( + horizontal: _T4Space.sm, + vertical: _T4Space.xxs, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(_T4Radius.lg), + border: Border.all( + color: showingHostManager + ? scheme.primary + : scheme.outlineVariant, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.circle, size: 6, color: dotColor), + const SizedBox(width: _T4Space.xs), + Flexible( + child: Text( + hostLabel, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall, + ), + ), + const SizedBox(width: _T4Space.xxs), + Icon( + Icons.expand_more, + size: _T4Size.indicator, + color: scheme.onSurfaceVariant, + ), + ], + ), + ), + ), + ), + ), ), ); } @@ -469,6 +502,8 @@ final class _SessionTile extends StatefulWidget { enum _SessionAction { rename, terminate, archive, restore, delete } final class _SessionTileState extends State<_SessionTile> { + bool _hovered = false; + bool get _canManage => widget.state.connectionPhase == ConnectionPhase.ready && widget.state.grantedCapabilities.contains('sessions.manage') && @@ -628,12 +663,28 @@ final class _SessionTileState extends State<_SessionTile> { @override Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; + final theme = Theme.of(context); + final scheme = theme.colorScheme; final session = widget.session; final title = session.title.trim().isEmpty ? 'Untitled session' : session.title; + final status = session.status.trim().isEmpty ? 'idle' : session.status; final canArchiveOrDelete = _canManage && !session.working; + final touch = switch (theme.platform) { + TargetPlatform.android || + TargetPlatform.iOS || + TargetPlatform.fuchsia => true, + TargetPlatform.linux || + TargetPlatform.macOS || + TargetPlatform.windows => false, + }; + final showMenu = touch || _hovered || widget.selected; + final dotColor = session.archived || session.status == 'closed' + ? scheme.onSurfaceVariant.withValues(alpha: 0.5) + : session.working + ? scheme.primary + : scheme.outlineVariant; return Semantics( button: true, @@ -641,68 +692,97 @@ final class _SessionTileState extends State<_SessionTile> { label: '$title, ${session.projectName}, ' '${session.archived ? 'archived, ' : ''}${session.status}', - child: ListTile( - selected: widget.selected, - selectedTileColor: scheme.secondaryContainer, - title: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis), - subtitle: Text( - session.status.trim().isEmpty ? 'idle' : session.status, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - trailing: widget.pending - ? const SizedBox.square( - dimension: _T4Size.indicator, - child: CircularProgressIndicator( - strokeWidth: _T4Size.thinStroke, - semanticsLabel: 'Selecting session', - ), - ) - : PopupMenuButton<_SessionAction>( - tooltip: 'Session actions', - enabled: _canManage, - onSelected: (action) => unawaited(_run(action)), - itemBuilder: (context) => [ - if (!session.archived) - const PopupMenuItem( - value: _SessionAction.rename, - child: Text('Rename'), - ), - if (!session.archived && session.status != 'closed') - const PopupMenuItem( - value: _SessionAction.terminate, - child: Text('Terminate runtime'), - ), - if (!session.archived) - PopupMenuItem( - value: _SessionAction.archive, - enabled: canArchiveOrDelete, - child: Text( - session.working - ? 'Archive (terminate runtime first)' - : 'Archive', + child: MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: ListTile( + selected: widget.selected, + selectedTileColor: scheme.secondaryContainer, + title: Tooltip( + message: '$title — $status', + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith(fontSize: 13), + ), + ), + trailing: widget.pending + ? const SizedBox.square( + dimension: _T4Size.indicator, + child: CircularProgressIndicator( + strokeWidth: _T4Size.thinStroke, + semanticsLabel: 'Selecting session', + ), + ) + : Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (session.working) + SizedBox.square( + dimension: 12, + child: CircularProgressIndicator( + strokeWidth: _T4Size.thinStroke, + color: scheme.primary, + semanticsLabel: 'Session working', + ), + ) + else + Icon(Icons.circle, size: 6, color: dotColor), + Visibility( + visible: showMenu, + maintainSize: true, + maintainAnimation: true, + maintainState: true, + child: PopupMenuButton<_SessionAction>( + tooltip: 'Session actions', + enabled: _canManage, + onSelected: (action) => unawaited(_run(action)), + itemBuilder: (context) => [ + if (!session.archived) + const PopupMenuItem( + value: _SessionAction.rename, + child: Text('Rename'), + ), + if (!session.archived && session.status != 'closed') + const PopupMenuItem( + value: _SessionAction.terminate, + child: Text('Terminate runtime'), + ), + if (!session.archived) + PopupMenuItem( + value: _SessionAction.archive, + enabled: canArchiveOrDelete, + child: Text( + session.working + ? 'Archive (terminate runtime first)' + : 'Archive', + ), + ), + if (session.archived) + const PopupMenuItem( + value: _SessionAction.restore, + child: Text('Restore'), + ), + PopupMenuItem( + value: _SessionAction.delete, + enabled: canArchiveOrDelete, + child: Text( + session.working + ? 'Delete (terminate runtime first)' + : 'Delete permanently', + ), + ), + ], + icon: widget.selected + ? const Icon(Icons.more_horiz) + : const Icon(Icons.more_vert), ), ), - if (session.archived) - const PopupMenuItem( - value: _SessionAction.restore, - child: Text('Restore'), - ), - PopupMenuItem( - value: _SessionAction.delete, - enabled: canArchiveOrDelete, - child: Text( - session.working - ? 'Delete (terminate runtime first)' - : 'Delete permanently', - ), - ), - ], - icon: widget.selected - ? const Icon(Icons.more_horiz) - : const Icon(Icons.more_vert), - ), - onTap: widget.pending ? null : () => unawaited(widget.onTap()), + ], + ), + onTap: widget.pending ? null : () => unawaited(widget.onTap()), + ), ), ); } diff --git a/apps/flutter/lib/src/ui/t4_app.dart b/apps/flutter/lib/src/ui/t4_app.dart index bdf650e1..b2403752 100644 --- a/apps/flutter/lib/src/ui/t4_app.dart +++ b/apps/flutter/lib/src/ui/t4_app.dart @@ -4,11 +4,12 @@ import 'dart:async'; import 'dart:convert'; import 'package:file_selector/file_selector.dart'; +import 'package:flutter/foundation.dart' show defaultTargetPlatform; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:xterm/xterm.dart'; -import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; +import 'transcript_markdown.dart'; import '../client/app_state.dart'; import '../client/model_labels.dart'; import '../platform/platform_lifecycle.dart'; @@ -16,8 +17,11 @@ import '../protocol/protocol.dart'; import '../platform/platform_lifecycle_controller.dart'; part 'adaptive_session_shell.dart'; +part 'access_mode_selector.dart'; +part 'command_palette.dart'; part 'attention_pane.dart'; part 'conversation_pane.dart'; +part 'context_panel.dart'; part 'developer_surfaces.dart'; part 'host_management.dart'; part 'quick_open_dialog.dart'; @@ -37,6 +41,7 @@ final class T4App extends StatelessWidget { required this.state, required this.actions, required this.credentialsAreVolatile, + this.demoMode = false, this.platformState = const PlatformLifecycleViewState.initial(), this.platformActions, super.key, @@ -45,6 +50,7 @@ final class T4App extends StatelessWidget { final T4ViewState state; final T4Actions actions; final bool credentialsAreVolatile; + final bool demoMode; final PlatformLifecycleViewState platformState; final PlatformLifecycleActions? platformActions; @@ -60,7 +66,16 @@ final class T4App extends StatelessWidget { T4ThemePreference.light => ThemeMode.light, T4ThemePreference.dark => ThemeMode.dark, }, - home: credentialsAreVolatile + home: demoMode + ? _DemoModeShell( + child: _AdaptiveSessionShell( + state: state, + actions: actions, + platformState: platformState, + platformActions: platformActions, + ), + ) + : credentialsAreVolatile ? _VolatileCredentialsShell( child: _AdaptiveSessionShell( state: state, @@ -79,6 +94,54 @@ final class T4App extends StatelessWidget { } } +final class _DemoModeShell extends StatelessWidget { + const _DemoModeShell({required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return Column( + children: [ + Semantics( + container: true, + label: 'Public preview using sample data. Actions are disabled.', + child: Material( + color: colors.secondaryContainer, + child: SafeArea( + bottom: false, + child: SizedBox( + height: 32, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.visibility_outlined, + size: 16, + color: colors.onSecondaryContainer, + ), + const SizedBox(width: 8), + Flexible( + child: Text( + 'Public preview · sample data · actions disabled', + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelMedium + ?.copyWith(color: colors.onSecondaryContainer), + ), + ), + ], + ), + ), + ), + ), + ), + Expanded(child: child), + ], + ); + } +} + final class _VolatileCredentialsShell extends StatelessWidget { const _VolatileCredentialsShell({required this.child}); diff --git a/apps/flutter/lib/src/ui/t4_theme.dart b/apps/flutter/lib/src/ui/t4_theme.dart index fefbcf2c..724e16a1 100644 --- a/apps/flutter/lib/src/ui/t4_theme.dart +++ b/apps/flutter/lib/src/ui/t4_theme.dart @@ -270,6 +270,22 @@ abstract final class _T4Theme { static ThemeData dark() => _build(scheme: _T4Palette.darkScheme, semantic: _T4Palette.darkSemantic); + /// Compact density on desktop targets (native and desktop web); standard on + /// touch platforms. On web, [defaultTargetPlatform] reflects the host OS, so + /// desktop browsers resolve to macOS/Windows/Linux and stay compact. + static VisualDensity get _adaptiveDensity { + switch (defaultTargetPlatform) { + case TargetPlatform.macOS: + case TargetPlatform.windows: + case TargetPlatform.linux: + return VisualDensity.compact; + case TargetPlatform.android: + case TargetPlatform.iOS: + case TargetPlatform.fuchsia: + return VisualDensity.standard; + } + } + static ThemeData _build({ required ColorScheme scheme, required T4SemanticColors semantic, @@ -280,57 +296,92 @@ abstract final class _T4Theme { colorScheme: scheme, fontFamily: _T4Typography.sansFamily, ); - final textTheme = base.textTheme - .apply( - fontFamily: _T4Typography.sansFamily, - bodyColor: scheme.onSurface, - displayColor: scheme.onSurface, - ) - .copyWith( - headlineSmall: base.textTheme.headlineSmall?.copyWith( - fontFamily: _T4Typography.sansFamily, - fontWeight: FontWeight.w600, - ), - titleLarge: base.textTheme.titleLarge?.copyWith( - fontFamily: _T4Typography.sansFamily, - fontWeight: FontWeight.w600, - ), - titleMedium: base.textTheme.titleMedium?.copyWith( - fontFamily: _T4Typography.sansFamily, - fontWeight: FontWeight.w600, - ), - titleSmall: base.textTheme.titleSmall?.copyWith( - fontFamily: _T4Typography.sansFamily, - fontWeight: FontWeight.w600, - ), - labelLarge: base.textTheme.labelLarge?.copyWith( - fontFamily: _T4Typography.sansFamily, - fontWeight: FontWeight.w500, - ), - labelMedium: base.textTheme.labelMedium?.copyWith( - fontFamily: _T4Typography.sansFamily, - fontWeight: FontWeight.w500, - ), - labelSmall: base.textTheme.labelSmall?.copyWith( - fontFamily: _T4Typography.sansFamily, - fontWeight: FontWeight.w500, - letterSpacing: 0.3, - ), - ); - final minimumSize = WidgetStatePropertyAll( - Size.square(_T4Layout.minimumTouchTarget), + final baseText = base.textTheme.apply( + fontFamily: _T4Typography.sansFamily, + bodyColor: scheme.onSurface, + displayColor: scheme.onSurface, + ); + final textTheme = baseText.copyWith( + headlineSmall: baseText.headlineSmall?.copyWith( + fontSize: 20, + fontWeight: FontWeight.w600, + fontVariations: const [FontVariation('wght', 650)], + ), + titleLarge: baseText.titleLarge?.copyWith( + fontSize: 17, + fontWeight: FontWeight.w600, + fontVariations: const [FontVariation('wght', 650)], + ), + titleMedium: baseText.titleMedium?.copyWith( + fontSize: 15, + fontWeight: FontWeight.w600, + ), + titleSmall: baseText.titleSmall?.copyWith( + fontSize: 13, + fontWeight: FontWeight.w600, + ), + bodyLarge: baseText.bodyLarge?.copyWith(fontSize: 14), + bodyMedium: baseText.bodyMedium?.copyWith(fontSize: 13, height: 1.45), + bodySmall: baseText.bodySmall?.copyWith(fontSize: 12), + labelLarge: baseText.labelLarge?.copyWith( + fontSize: 13, + fontWeight: FontWeight.w600, + ), + labelMedium: baseText.labelMedium?.copyWith( + fontSize: 12, + fontWeight: FontWeight.w500, + ), + labelSmall: baseText.labelSmall?.copyWith( + fontSize: 11, + fontWeight: FontWeight.w500, + letterSpacing: 0.2, + ), + ); + const minimumSize = WidgetStatePropertyAll(Size(0, 32)); + final touchPlatform = + defaultTargetPlatform == TargetPlatform.android || + defaultTargetPlatform == TargetPlatform.iOS; + final iconButtonMinimumSize = WidgetStatePropertyAll( + Size.square(touchPlatform ? _T4Layout.minimumTouchTarget : 28), + ); + final iconButtonPadding = WidgetStatePropertyAll( + EdgeInsets.all(touchPlatform ? _T4Space.xs : _T4Space.xxs), ); final controlShape = WidgetStatePropertyAll( - RoundedRectangleBorder(borderRadius: BorderRadius.circular(_T4Radius.md)), + RoundedRectangleBorder(borderRadius: BorderRadius.circular(_T4Radius.sm)), ); - final controlPadding = WidgetStatePropertyAll( - const EdgeInsets.symmetric(horizontal: 12), + const controlPadding = WidgetStatePropertyAll( + EdgeInsets.symmetric(horizontal: 14, vertical: 8), ); + final menuShape = RoundedRectangleBorder( + borderRadius: BorderRadius.circular(_T4Radius.md), + side: BorderSide(color: scheme.outlineVariant), + ); + final menuStyle = MenuStyle( + backgroundColor: WidgetStatePropertyAll( + scheme.surfaceContainerLowest, + ), + surfaceTintColor: const WidgetStatePropertyAll(Colors.transparent), + elevation: const WidgetStatePropertyAll(6), + shape: WidgetStatePropertyAll(menuShape), + minimumSize: const WidgetStatePropertyAll(Size(180, 0)), + padding: const WidgetStatePropertyAll( + EdgeInsets.symmetric(vertical: _T4Space.xxs), + ), + ); + final isDark = scheme.brightness == Brightness.dark; + final tooltipBackground = isDark + ? scheme.surfaceContainerHighest + : scheme.inverseSurface; + final tooltipForeground = isDark + ? scheme.onSurface + : scheme.onInverseSurface; return base.copyWith( scaffoldBackgroundColor: scheme.surface, textTheme: textTheme, - visualDensity: VisualDensity.standard, + visualDensity: _adaptiveDensity, + iconTheme: base.iconTheme.copyWith(size: 20), extensions: >[semantic], appBarTheme: AppBarTheme( backgroundColor: scheme.surface, @@ -347,28 +398,28 @@ abstract final class _T4Theme { ), inputDecorationTheme: InputDecorationTheme( filled: true, - fillColor: scheme.surfaceContainerLowest, - contentPadding: const EdgeInsets.symmetric( - horizontal: _T4Space.md, - vertical: _T4Space.sm, + fillColor: scheme.onSurface.withValues(alpha: 0.03), + isDense: true, + floatingLabelBehavior: FloatingLabelBehavior.never, + contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 9), + hintStyle: textTheme.bodyMedium?.copyWith( + fontSize: 13, + color: scheme.onSurfaceVariant.withValues(alpha: 0.75), ), border: OutlineInputBorder( - borderRadius: BorderRadius.circular(_T4Radius.md), - borderSide: BorderSide(color: scheme.outline), + borderRadius: BorderRadius.circular(_T4Radius.sm), + borderSide: BorderSide(color: scheme.outlineVariant), ), enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(_T4Radius.md), - borderSide: BorderSide(color: scheme.outline), + borderRadius: BorderRadius.circular(_T4Radius.sm), + borderSide: BorderSide(color: scheme.outlineVariant), ), focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(_T4Radius.md), - borderSide: BorderSide( - color: scheme.primary, - width: _T4Size.thinStroke, - ), + borderRadius: BorderRadius.circular(_T4Radius.sm), + borderSide: BorderSide(color: scheme.primary, width: 1.5), ), disabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(_T4Radius.md), + borderRadius: BorderRadius.circular(_T4Radius.sm), borderSide: BorderSide(color: scheme.outlineVariant), ), floatingLabelStyle: TextStyle(color: scheme.primary), @@ -402,7 +453,13 @@ abstract final class _T4Theme { ), ), iconButtonTheme: IconButtonThemeData( - style: ButtonStyle(minimumSize: minimumSize, shape: controlShape), + style: ButtonStyle( + iconSize: const WidgetStatePropertyAll(19), + minimumSize: iconButtonMinimumSize, + padding: iconButtonPadding, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + shape: controlShape, + ), ), cardTheme: CardThemeData( color: scheme.surfaceContainerLowest, @@ -419,10 +476,10 @@ abstract final class _T4Theme { surfaceTintColor: Colors.transparent, elevation: 16, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(_T4Radius.lg), + borderRadius: BorderRadius.circular(12), side: BorderSide(color: scheme.outlineVariant), ), - titleTextStyle: textTheme.headlineSmall, + titleTextStyle: textTheme.titleMedium, contentTextStyle: textTheme.bodyMedium, ), bottomSheetTheme: BottomSheetThemeData( @@ -450,7 +507,10 @@ abstract final class _T4Theme { padding: const EdgeInsets.symmetric(horizontal: _T4Space.xs), ), listTileTheme: ListTileThemeData( - minTileHeight: _T4Layout.minimumTouchTarget, + dense: true, + contentPadding: const EdgeInsets.symmetric(horizontal: _T4Space.sm), + minVerticalPadding: 6, + horizontalTitleGap: _T4Space.xs, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(_T4Radius.sm), ), @@ -469,15 +529,30 @@ abstract final class _T4Theme { borderRadius: BorderRadius.circular(_T4Radius.sm), ), ), + menuTheme: MenuThemeData(style: menuStyle), + menuButtonTheme: MenuButtonThemeData( + style: ButtonStyle( + minimumSize: const WidgetStatePropertyAll(Size(0, 34)), + padding: const WidgetStatePropertyAll( + EdgeInsets.symmetric( + horizontal: _T4Space.sm, + vertical: _T4Space.xxs, + ), + ), + textStyle: WidgetStatePropertyAll(textTheme.labelMedium), + ), + ), + dropdownMenuTheme: DropdownMenuThemeData( + textStyle: textTheme.labelMedium, + menuStyle: menuStyle, + ), popupMenuTheme: PopupMenuThemeData( color: scheme.surfaceContainerLowest, surfaceTintColor: Colors.transparent, - elevation: 8, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(_T4Radius.sm), - side: BorderSide(color: scheme.outlineVariant), - ), - textStyle: textTheme.bodyMedium, + elevation: 6, + shape: menuShape, + textStyle: textTheme.labelMedium, + menuPadding: const EdgeInsets.symmetric(vertical: _T4Space.xxs), ), snackBarTheme: SnackBarThemeData( behavior: SnackBarBehavior.floating, @@ -490,13 +565,14 @@ abstract final class _T4Theme { ), ), tooltipTheme: TooltipThemeData( - waitDuration: _T4Motion.short, + waitDuration: const Duration(milliseconds: 450), decoration: BoxDecoration( - color: scheme.inverseSurface, - borderRadius: BorderRadius.circular(_T4Radius.sm), + color: tooltipBackground, + borderRadius: BorderRadius.circular(_T4Radius.xs), ), textStyle: textTheme.labelSmall?.copyWith( - color: scheme.onInverseSurface, + fontSize: 11.5, + color: tooltipForeground, ), ), scrollbarTheme: ScrollbarThemeData( diff --git a/apps/flutter/lib/src/ui/transcript_markdown.dart b/apps/flutter/lib/src/ui/transcript_markdown.dart new file mode 100644 index 00000000..742ebcb5 --- /dev/null +++ b/apps/flutter/lib/src/ui/transcript_markdown.dart @@ -0,0 +1,370 @@ +// Markdown rendering for transcript content. +// +// Standalone library (not a `part of` t4_app.dart) so it can be consumed by +// later waves without pulling in the shell. Styling is derived entirely from +// `Theme.of(context)`; no private theme tokens are referenced. + +// The `markdown` package is a direct dependency of flutter_markdown_plus and +// is required here only for the `MarkdownElementBuilder` override signatures. +// ignore_for_file: depend_on_referenced_packages + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; +import 'package:markdown/markdown.dart' as md; +import 'package:re_highlight/languages/bash.dart'; +import 'package:re_highlight/languages/c.dart'; +import 'package:re_highlight/languages/cpp.dart'; +import 'package:re_highlight/languages/css.dart'; +import 'package:re_highlight/languages/dart.dart'; +import 'package:re_highlight/languages/go.dart'; +import 'package:re_highlight/languages/java.dart'; +import 'package:re_highlight/languages/javascript.dart'; +import 'package:re_highlight/languages/json.dart'; +import 'package:re_highlight/languages/kotlin.dart'; +import 'package:re_highlight/languages/markdown.dart'; +import 'package:re_highlight/languages/python.dart'; +import 'package:re_highlight/languages/rust.dart'; +import 'package:re_highlight/languages/sql.dart'; +import 'package:re_highlight/languages/swift.dart'; +import 'package:re_highlight/languages/typescript.dart'; +import 'package:re_highlight/languages/xml.dart'; +import 'package:re_highlight/languages/yaml.dart'; +import 'package:re_highlight/re_highlight.dart'; +import 'package:re_highlight/styles/atom-one-dark.dart'; +import 'package:re_highlight/styles/atom-one-light.dart'; + +const String _monoFamily = 'JetBrains Mono'; + +/// Renders markdown transcript content with T4 typography. +/// +/// Fenced code blocks render through [CodeBlock]. Body text uses plain +/// `Text.rich` spans, so the widget is selectable when embedded in an +/// ambient [SelectionArea]; set [selectable] to false to opt a subtree out. +class TranscriptMarkdown extends StatelessWidget { + const TranscriptMarkdown({ + required this.data, + this.selectable = true, + super.key, + }); + + /// Raw markdown source. + final String data; + + /// Whether an ambient [SelectionArea] may select this content. + final bool selectable; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final Widget body = MarkdownBody( + data: data, + // Plain RichText spans cooperate with an ambient SelectionArea; + // SelectableText (selectable: true) would fight it. + selectable: false, + styleSheet: _styleSheetFor(theme), + builders: {'code': _FencedCodeBuilder()}, + ); + if (selectable) { + return SelectionArea(child: body); + } + return SelectionContainer.disabled(child: body); + } + + static MarkdownStyleSheet _styleSheetFor(ThemeData theme) { + final ColorScheme scheme = theme.colorScheme; + final TextTheme text = theme.textTheme; + final TextStyle body = (text.bodyMedium ?? const TextStyle()).copyWith( + fontSize: 13, + height: 1.55, + ); + TextStyle heading(TextStyle? base, double size) => + (base ?? body).copyWith(fontSize: size, height: 1.3); + + return MarkdownStyleSheet.fromTheme(theme).copyWith( + p: body, + pPadding: EdgeInsets.zero, + blockSpacing: 10, + listIndent: 20, + listBullet: body.copyWith(color: scheme.onSurfaceVariant), + h1: heading(text.titleLarge, 19), + h2: heading(text.titleMedium, 16.5), + h3: heading(text.titleSmall, 14.5), + h4: heading(text.labelLarge, 13.5), + h5: heading(text.labelLarge, 13), + h6: heading(text.labelMedium, 12.5), + // Inline code chip; fenced blocks are replaced by CodeBlock below. + code: body.copyWith( + fontFamily: _monoFamily, + fontSize: 12.5, + height: 1.4, + backgroundColor: scheme.surfaceContainerHighest.withValues(alpha: 0.5), + ), + blockquote: body.copyWith(color: scheme.onSurfaceVariant), + blockquotePadding: const EdgeInsets.fromLTRB(12, 4, 8, 4), + blockquoteDecoration: BoxDecoration( + border: Border( + left: BorderSide( + color: scheme.primary.withValues(alpha: 0.45), + width: 3, + ), + ), + ), + tableHead: body.copyWith(fontSize: 12.5, fontWeight: FontWeight.w600), + tableBody: body.copyWith(fontSize: 12.5), + tableBorder: TableBorder.all(color: scheme.outlineVariant, width: 1), + tableCellsPadding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + tableHeadCellsDecoration: BoxDecoration( + color: scheme.surfaceContainerHighest.withValues(alpha: 0.5), + ), + // CodeBlock paints its own container; keep the default `pre` wrapper + // from painting a second background behind it. + codeblockDecoration: const BoxDecoration(), + codeblockPadding: EdgeInsets.zero, + horizontalRuleDecoration: BoxDecoration( + border: Border(top: BorderSide(color: scheme.outlineVariant, width: 1)), + ), + ); + } +} + +/// Routes fenced/indented code blocks to [CodeBlock]. +/// +/// Registered under the `code` tag: returning a widget replaces the default +/// `pre` scroll view, while returning null for inline `code` spans keeps the +/// stylesheet-driven inline rendering (and unbroken text flow). +class _FencedCodeBuilder extends MarkdownElementBuilder { + @override + Widget? visitElementAfterWithContext( + BuildContext context, + md.Element element, + TextStyle? preferredStyle, + TextStyle? parentStyle, + ) { + final String classAttribute = element.attributes['class'] ?? ''; + final String? language = classAttribute.startsWith('language-') + ? classAttribute.substring('language-'.length) + : null; + String code = element.textContent; + final bool isBlock = language != null || code.contains('\n'); + if (!isBlock) { + return null; + } + if (code.endsWith('\n')) { + code = code.substring(0, code.length - 1); + } + return CodeBlock(code: code, language: language); + } +} + +/// A fenced code block with a language header, copy button, and +/// `re_highlight` syntax highlighting. +class CodeBlock extends StatefulWidget { + const CodeBlock({required this.code, this.language, super.key}); + + /// Code text without the trailing fence newline. + final String code; + + /// Info-string language (e.g. `dart`), if one was provided. + final String? language; + + @override + State createState() => _CodeBlockState(); +} + +class _CodeBlockState extends State { + static const Duration _copyFeedback = Duration(milliseconds: 1500); + + bool _copied = false; + Timer? _copyTimer; + + @override + void dispose() { + _copyTimer?.cancel(); + super.dispose(); + } + + Future _copy() async { + await Clipboard.setData(ClipboardData(text: widget.code)); + if (!mounted) { + return; + } + setState(() => _copied = true); + _copyTimer?.cancel(); + _copyTimer = Timer(_copyFeedback, () { + if (mounted) { + setState(() => _copied = false); + } + }); + } + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final ColorScheme scheme = theme.colorScheme; + final Map syntaxTheme = + theme.brightness == Brightness.dark + ? atomOneDarkTheme + : atomOneLightTheme; + final TextStyle baseStyle = TextStyle( + fontFamily: _monoFamily, + fontSize: 12.5, + height: 1.55, + color: syntaxTheme['root']?.color ?? scheme.onSurface, + ); + final String label = switch (widget.language?.trim()) { + final String language when language.isNotEmpty => language, + _ => 'text', + }; + + return Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: scheme.surfaceContainerHighest.withValues(alpha: 0.35), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: scheme.outlineVariant, width: 1), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildHeader(theme, label), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.fromLTRB(12, 10, 12, 12), + child: Text.rich( + _highlightedSpan( + widget.code, + widget.language, + baseStyle, + syntaxTheme, + ), + softWrap: false, + ), + ), + ], + ), + ); + } + + Widget _buildHeader(ThemeData theme, String label) { + final ColorScheme scheme = theme.colorScheme; + return Container( + padding: const EdgeInsetsDirectional.only(start: 12, end: 4), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide(color: scheme.outlineVariant, width: 1), + ), + ), + child: Row( + children: [ + Expanded( + child: SelectionContainer.disabled( + child: Text( + label, + style: (theme.textTheme.labelSmall ?? const TextStyle()) + .copyWith( + fontFamily: _monoFamily, + color: scheme.onSurfaceVariant, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ), + IconButton( + onPressed: _copy, + tooltip: _copied ? 'Copied' : 'Copy', + iconSize: 15, + visualDensity: VisualDensity.compact, + icon: Icon( + _copied ? Icons.check_rounded : Icons.copy_rounded, + color: _copied ? scheme.primary : scheme.onSurfaceVariant, + ), + ), + ], + ), + ); + } +} + +/// Lazily constructed highlighter with the transcript's supported grammars. +final Highlight _highlighter = () { + final Highlight highlight = Highlight(); + highlight.registerLanguages({ + 'bash': langBash, + 'c': langC, + 'cpp': langCpp, + 'css': langCss, + 'dart': langDart, + 'go': langGo, + 'java': langJava, + 'javascript': langJavascript, + 'json': langJson, + 'kotlin': langKotlin, + 'markdown': langMarkdown, + 'python': langPython, + 'rust': langRust, + 'sql': langSql, + 'swift': langSwift, + 'typescript': langTypescript, + 'xml': langXml, + 'yaml': langYaml, + }); + return highlight; +}(); + +const Map _languageAliases = { + 'c++': 'cpp', + 'cc': 'cpp', + 'cjs': 'javascript', + 'golang': 'go', + 'htm': 'xml', + 'html': 'xml', + 'hpp': 'cpp', + 'js': 'javascript', + 'jsx': 'javascript', + 'kt': 'kotlin', + 'md': 'markdown', + 'mjs': 'javascript', + 'py': 'python', + 'rs': 'rust', + 'sh': 'bash', + 'shell': 'bash', + 'ts': 'typescript', + 'tsx': 'typescript', + 'yml': 'yaml', + 'zsh': 'bash', +}; + +TextSpan _highlightedSpan( + String code, + String? language, + TextStyle baseStyle, + Map syntaxTheme, +) { + final String? normalized = _normalizeLanguage(language); + if (normalized == null) { + return TextSpan(text: code, style: baseStyle); + } + final HighlightResult result = _highlighter.highlight( + code: code, + language: normalized, + ); + final TextSpanRenderer renderer = TextSpanRenderer(baseStyle, syntaxTheme); + result.render(renderer); + return renderer.span ?? TextSpan(text: code, style: baseStyle); +} + +String? _normalizeLanguage(String? language) { + final String? raw = language?.trim().toLowerCase(); + if (raw == null || raw.isEmpty) { + return null; + } + final String canonical = _languageAliases[raw] ?? raw; + return _highlighter.getLanguage(canonical) != null ? canonical : null; +} diff --git a/apps/flutter/lib/src/ui/usage_status_pane.dart b/apps/flutter/lib/src/ui/usage_status_pane.dart index 52f55f4c..c454e1d6 100644 --- a/apps/flutter/lib/src/ui/usage_status_pane.dart +++ b/apps/flutter/lib/src/ui/usage_status_pane.dart @@ -303,15 +303,19 @@ final class _UsageReportList extends StatelessWidget { @override Widget build(BuildContext context) { - final generated = DateTime.fromMillisecondsSinceEpoch(result.generatedAt); + final generated = result.generatedAt > 0 + ? DateTime.fromMillisecondsSinceEpoch(result.generatedAt) + : null; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text( - 'Updated ${_formatActivityTime(generated)}', - style: Theme.of(context).textTheme.labelSmall, - ), - const SizedBox(height: _T4Space.sm), + if (generated != null) ...[ + Text( + 'Updated ${_formatActivityTime(generated)}', + style: Theme.of(context).textTheme.labelSmall, + ), + const SizedBox(height: _T4Space.sm), + ], if (result.reports.isEmpty) const _StatusNotice( icon: Icons.data_usage, @@ -426,7 +430,7 @@ final class _UsageLimitMeter extends StatelessWidget { Padding( padding: const EdgeInsets.only(top: _T4Space.xxs), child: Text( - window.resetsAt == null + window.resetsAt == null || window.resetsAt! <= 0 ? window.label : '${window.label} · resets ${_formatActivityTime(DateTime.fromMillisecondsSinceEpoch(window.resetsAt!))}', style: Theme.of(context).textTheme.labelSmall, diff --git a/apps/flutter/pubspec.lock b/apps/flutter/pubspec.lock index 2239b0d0..5fdfd177 100644 --- a/apps/flutter/pubspec.lock +++ b/apps/flutter/pubspec.lock @@ -495,6 +495,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.2.2" + re_highlight: + dependency: "direct main" + description: + name: re_highlight + sha256: "6c4ac3f76f939fb7ca9df013df98526634e17d8f7460e028bd23a035870024f2" + url: "https://pub.dev" + source: hosted + version: "0.0.3" record_use: dependency: transitive description: diff --git a/apps/flutter/pubspec.yaml b/apps/flutter/pubspec.yaml index 75d1ba78..e3456461 100644 --- a/apps/flutter/pubspec.yaml +++ b/apps/flutter/pubspec.yaml @@ -16,6 +16,7 @@ dependencies: flutter_markdown_plus: ^1.0.12 file_selector: ^1.1.0 xterm: ^4.0.0 + re_highlight: ^0.0.3 dev_dependencies: flutter_test: diff --git a/apps/flutter/test/demo/demo_app_test.dart b/apps/flutter/test/demo/demo_app_test.dart new file mode 100644 index 00000000..e180b43d --- /dev/null +++ b/apps/flutter/test/demo/demo_app_test.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:t4code/src/demo/demo_app.dart'; + +void main() { + testWidgets('public demo renders the canonical Flutter session workspace', ( + tester, + ) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(1280, 800); + addTearDown(tester.view.reset); + + await tester.pumpWidget(const T4DemoApp()); + // The demo seeds a working session whose rail spinner animates forever, + // so settle with bounded pumps instead of pumpAndSettle. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + await tester.pump(const Duration(milliseconds: 400)); + + expect(find.text('T4 CODE'), findsWidgets); + expect( + find.text('Public preview · sample data · actions disabled'), + findsOneWidget, + ); + expect(find.text('Fix quick-open stale results'), findsWidgets); + expect( + find.textContaining( + 'Quick open now keys its cache', + findRichText: true, + ), + findsOneWidget, + ); + expect(find.text('Connect to T4'), findsNothing); + }); +} diff --git a/apps/flutter/test/ui/command_palette_test.dart b/apps/flutter/test/ui/command_palette_test.dart new file mode 100644 index 00000000..9b040090 --- /dev/null +++ b/apps/flutter/test/ui/command_palette_test.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:t4code/src/ui/t4_app.dart'; + +void main() { + testWidgets('command palette filters, runs on Enter, and closes', ( + tester, + ) async { + final ran = []; + final commands = [ + PaletteCommand( + id: 'open-settings', + title: 'Open Settings', + shortcutLabel: '⌘,', + run: () => ran.add('open-settings'), + ), + PaletteCommand( + id: 'new-session', + title: 'New Session', + shortcutLabel: '⌘N', + run: () => ran.add('new-session'), + ), + PaletteCommand( + id: 'toggle-inbox', + title: 'Toggle Inbox', + run: () => ran.add('toggle-inbox'), + ), + ]; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) => Center( + child: ElevatedButton( + onPressed: () => + showCommandPalette(context, commands: commands), + child: const Text('Palette'), + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Palette')); + await tester.pumpAndSettle(); + + // All three commands listed initially. + expect(find.byType(TextField), findsOneWidget); + expect(find.textContaining('Session'), findsOneWidget); + + // Filter down to just "New Session" via word-prefix fuzzy match. + await tester.enterText(find.byType(TextField), 'new ses'); + await tester.pump(); + expect( + find.byKey(const ValueKey('palette-command-new-session')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('palette-command-open-settings')), + findsNothing, + ); + + // Enter runs the single remaining command and closes the dialog. + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + + expect(ran, ['new-session']); + expect(find.byType(TextField), findsNothing); + }); +} diff --git a/apps/flutter/test/ui/host_flow_test.dart b/apps/flutter/test/ui/host_flow_test.dart index b7aadb52..6058af99 100644 --- a/apps/flutter/test/ui/host_flow_test.dart +++ b/apps/flutter/test/ui/host_flow_test.dart @@ -242,6 +242,8 @@ void main() { size: wideDesktop, ); + await tester.tap(find.byTooltip('Host menu')); + await tester.pumpAndSettle(); await tester.tap(find.text('Manage hosts')); await tester.pumpAndSettle(); @@ -356,7 +358,9 @@ void main() { size: wideDesktop, ); - await tester.tap(find.widgetWithText(TextButton, 'Disconnect')); + await tester.tap(find.byTooltip('Host menu')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Disconnect')); await tester.pumpAndSettle(); expect(actions.disconnectCalls, 1); }); @@ -404,7 +408,7 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Navigation'), findsOneWidget); - expect(find.text('Manage hosts'), findsOneWidget); + expect(find.byTooltip('Host menu'), findsOneWidget); expect(find.byType(Drawer), findsOneWidget); }); @@ -429,7 +433,7 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Navigation'), findsOneWidget); - expect(find.text('Manage hosts'), findsOneWidget); + expect(find.byTooltip('Host menu'), findsOneWidget); }); testWidgets('unsigned macOS development mode is visibly identified', ( @@ -472,7 +476,7 @@ void main() { await tester.pumpAndSettle(); expect(find.byTooltip('Open navigation'), findsNothing); - expect(find.text('Manage hosts'), findsOneWidget); + expect(find.byTooltip('Host menu'), findsOneWidget); expect(find.text('T4'), findsOneWidget); }); testWidgets( @@ -521,8 +525,8 @@ void main() { ); await pumpApp(tester, state: state, actions: actions, size: wideDesktop); - expect(find.text('Project Alpha'), findsOneWidget); - expect(find.text('Project Beta'), findsOneWidget); + expect(find.text('PROJECT ALPHA'), findsOneWidget); + expect(find.text('PROJECT BETA'), findsOneWidget); expect(find.text('Archived investigation'), findsNothing); await tester.enterText( @@ -700,11 +704,12 @@ void main() { expect(find.widgetWithText(TextField, 'Alpha draft'), findsOneWidget); expect(find.text('openai-codex/gpt-5.6-sol'), findsOneWidget); expect(find.text('medium'), findsOneWidget); - expect(find.text('Fast'), findsOneWidget); expect(find.text('Stop'), findsOneWidget); expect(find.text('Queue (2)'), findsOneWidget); - await tester.tap(find.widgetWithText(FilledButton, 'Steer')); + // The circular send button doubles as the steer affordance while a + // turn is active. + await tester.tap(find.byTooltip('Steer')); await tester.pumpAndSettle(); expect(actions.submittedPrompts, ['Alpha draft']); }, @@ -742,16 +747,17 @@ void main() { ); await tester.enterText(find.byType(TextField).last, 'Cannot send'); await tester.pump(); - expect( - tester - .widget(find.widgetWithText(FilledButton, 'Send')) - .onPressed, - isNull, + final send = tester.widget( + find.ancestor( + of: find.byIcon(Icons.arrow_upward), + matching: find.byType(IconButton), + ), ); + expect(send.onPressed, isNull); }, ); testWidgets( - 'Fast toggle reads as an inactive toggle when available and off, and selected when on', + 'Fast toggles from the model selector menu and reflects the current state', (tester) async { final profile = HostProfile.parseTailnetAddress( 'https://alpha.tailnet-name.ts.net', @@ -790,23 +796,8 @@ void main() { ), ); - Color? chipBackground(WidgetTester tester) { - // The RawChip under the FilterChip paints its resolved background as a - // ShapeDecoration on an Ink widget; reading it observes the effective - // selected vs unselected appearance without asserting on source text. - final ink = tester.widget( - find - .ancestor(of: find.text('Fast'), matching: find.byType(Ink)) - .first, - ); - final decoration = ink.decoration as ShapeDecoration; - return decoration.color; - } - - // Available but off: the chip keeps its normal filled surface and - // border (unchanged from the other dropdown chips), but the literal - // "Fast" label foreground is muted grey so the toggle reads inactive - // without the button itself looking selected. + // Fast lives inside the model selector menu as a checkable item; it has + // no standalone chip in the composer row. await pumpApp( tester, state: stateFor(fastEnabled: false), @@ -814,27 +805,21 @@ void main() { size: compactPhone, ); await tester.pumpAndSettle(); - final scheme = Theme.of( - tester.element(find.widgetWithText(FilterChip, 'Fast')), - ).colorScheme; - final offChip = tester.widget( - find.widgetWithText(FilterChip, 'Fast'), + expect(find.text('Fast'), findsNothing); + + await tester.tap(find.text('Fixture model')); + await tester.pumpAndSettle(); + final offItem = tester.widget( + find.widgetWithText(CheckboxMenuButton, 'Fast'), ); - expect(offChip.selected, isFalse); - // Normal filled chip surface, identical to the other composer chips. - expect(chipBackground(tester), scheme.surfaceContainer); - // The label foreground is the muted/grey variant color. - final offLabel = tester.widget(find.text('Fast')); - expect(offLabel.style?.color, scheme.onSurfaceVariant); - - // Tapping the off toggle requests enabling Fast. - await tester.tap(find.widgetWithText(FilterChip, 'Fast')); + expect(offItem.value, isFalse); + + // Activating the unchecked item requests enabling Fast. + await tester.tap(find.text('Fast')); await tester.pumpAndSettle(); expect(actions.selectedFastModes, [true]); - // On: the selected pink/primary-container surface and the chip's normal - // selected label foreground are unchanged (the label style is null, so - // the theme's selected label color applies). + // On: the menu item reads as checked. await tester.pumpWidget( T4App( state: stateFor(fastEnabled: true), @@ -843,78 +828,76 @@ void main() { ), ); await tester.pumpAndSettle(); - final onChip = tester.widget( - find.widgetWithText(FilterChip, 'Fast'), + await tester.tap(find.text('Fixture model')); + await tester.pumpAndSettle(); + final onItem = tester.widget( + find.widgetWithText(CheckboxMenuButton, 'Fast'), ); - expect(onChip.selected, isTrue); - expect(chipBackground(tester), scheme.primaryContainer); - final onLabel = tester.widget(find.text('Fast')); - expect(onLabel.style?.color, isNull); + expect(onItem.value, isTrue); }, ); - testWidgets( - 'slash menu finds aliases and explains terminal-only commands', - (tester) async { - final profile = HostProfile.parseTailnetAddress( - 'https://alpha.tailnet-name.ts.net', - ); - final state = T4ViewState( - connectionPhase: ConnectionPhase.ready, - hostDirectory: HostDirectory.empty().upsert(profile), - authenticationPhase: AuthenticationPhase.paired, - grantedCapabilities: t4RequestedCapabilities.toSet(), - selectedSessionId: 'session-alpha', - sessions: const [ - SessionSummary( - hostId: 'host-alpha', - sessionId: 'session-alpha', - projectId: 'project-alpha', - projectName: 'Project Alpha', - title: 'Capability-aware slash menu', - revision: 'revision-alpha', - status: 'idle', + testWidgets('slash menu finds aliases and explains terminal-only commands', ( + tester, + ) async { + final profile = HostProfile.parseTailnetAddress( + 'https://alpha.tailnet-name.ts.net', + ); + final state = T4ViewState( + connectionPhase: ConnectionPhase.ready, + hostDirectory: HostDirectory.empty().upsert(profile), + authenticationPhase: AuthenticationPhase.paired, + grantedCapabilities: t4RequestedCapabilities.toSet(), + selectedSessionId: 'session-alpha', + sessions: const [ + SessionSummary( + hostId: 'host-alpha', + sessionId: 'session-alpha', + projectId: 'project-alpha', + projectName: 'Project Alpha', + title: 'Capability-aware slash menu', + revision: 'revision-alpha', + status: 'idle', + ), + ], + composer: const SessionComposerState( + slashCommands: [ + ComposerSlashCommand( + name: '/compact', + aliases: ['/compress'], + description: 'Compact the active conversation', + insert: '/compact ', + ), + ComposerSlashCommand( + name: '/plan', + description: 'Toggle plan mode', + insert: '/plan ', + disabledReason: '/plan requires the OMP terminal interface.', ), ], - composer: const SessionComposerState( - slashCommands: [ - ComposerSlashCommand( - name: '/compact', - aliases: ['/compress'], - description: 'Compact the active conversation', - insert: '/compact ', - ), - ComposerSlashCommand( - name: '/plan', - description: 'Toggle plan mode', - insert: '/plan ', - disabledReason: '/plan requires the OMP terminal interface.', - ), - ], - ), - ); + ), + ); - await pumpApp( - tester, - state: state, - actions: _FakeActions(), - size: compactPhone, - ); - await tester.enterText(find.byType(TextField).last, '/'); - await tester.pump(); - expect(find.text('/compact'), findsOneWidget); - expect(find.text('/plan'), findsOneWidget); - expect( - find.text('/plan requires the OMP terminal interface.'), - findsOneWidget, - ); + await pumpApp( + tester, + state: state, + actions: _FakeActions(), + size: compactPhone, + ); + await tester.enterText(find.byType(TextField).last, '/'); + await tester.pump(); + expect(find.text('/compact'), findsOneWidget); + expect(find.text('/plan'), findsOneWidget); + expect( + find.text('/plan requires the OMP terminal interface.'), + findsOneWidget, + ); - await tester.enterText(find.byType(TextField).last, '/compress'); - await tester.pump(); - expect(find.text('/compact'), findsOneWidget); - expect(find.text('/plan'), findsNothing); - }, - ); + await tester.enterText(find.byType(TextField).last, '/compress'); + await tester.pump(); + expect(find.text('/compact'), findsOneWidget); + expect(find.text('/plan'), findsNothing); + }); testWidgets('keeps the latest message visible when the keyboard opens', ( tester, ) async { @@ -1100,6 +1083,127 @@ void main() { ); }); + testWidgets('collapses completed tool runs into a work group', ( + tester, + ) async { + await pumpApp( + tester, + state: keyboardTranscriptState( + messages: const [ + TranscriptMessage( + id: 'message-user', + role: MessageRole.user, + text: 'Fix the flaky test.', + ), + TranscriptMessage( + id: 'message-write', + role: MessageRole.tool, + kind: TranscriptKind.tool, + text: '', + toolName: 'files.write', + toolTitle: 'Write lib/main.dart', + toolArguments: '{"path": "lib/main.dart", "content": ""}', + toolSucceeded: true, + ), + TranscriptMessage( + id: 'message-test', + role: MessageRole.tool, + kind: TranscriptKind.tool, + text: '', + toolName: 'terminal.run', + toolTitle: 'Run flutter test', + toolArguments: '{"command": "flutter test"}', + toolSucceeded: true, + ), + TranscriptMessage( + id: 'message-assistant', + role: MessageRole.assistant, + text: 'Done.', + ), + ], + ), + actions: _FakeActions(), + size: compactPhone, + ); + + expect(find.text('Worked · 2 steps'), findsOneWidget); + expect(find.text('Edited 1 file'), findsOneWidget); + expect(find.text('main.dart'), findsOneWidget); + expect(find.text('Write lib/main.dart'), findsNothing); + expect(find.text('Run flutter test'), findsNothing); + + await tester.tap(find.text('Worked · 2 steps')); + await tester.pumpAndSettle(); + expect(find.text('Write lib/main.dart'), findsOneWidget); + expect(find.text('Run flutter test'), findsOneWidget); + }); + + testWidgets('renders actionable approvals inline below the transcript', ( + tester, + ) async { + final profile = HostProfile.parseTailnetAddress( + 'https://alpha.tailnet-name.ts.net', + ); + final actions = _FakeActions(); + final approval = AttentionItem( + key: 'session-alpha:approval:approval-inline', + kind: AttentionKind.approval, + sessionId: 'session-alpha', + sessionTitle: 'First investigation', + revision: 'revision-alpha', + title: 'Allow file write?', + summary: 'OMP wants to update lib/main.dart.', + at: DateTime.utc(2026, 7, 21), + requestId: 'approval-inline', + actionable: true, + ); + await pumpApp( + tester, + state: T4ViewState( + connectionPhase: ConnectionPhase.ready, + hostDirectory: HostDirectory.empty().upsert(profile), + authenticationPhase: AuthenticationPhase.paired, + grantedCapabilities: t4RequestedCapabilities.toSet(), + selectedSessionId: 'session-alpha', + sessions: const [ + SessionSummary( + hostId: 'host-alpha', + sessionId: 'session-alpha', + projectId: 'project-alpha', + projectName: 'Project Alpha', + title: 'First investigation', + revision: 'revision-alpha', + status: 'active', + ), + ], + messages: const [ + TranscriptMessage( + id: 'message-user', + role: MessageRole.user, + text: 'Please update main.dart.', + ), + TranscriptMessage( + id: 'message-assistant', + role: MessageRole.assistant, + text: 'Requesting write access now.', + ), + ], + attentionItems: [approval], + ), + actions: actions, + size: compactPhone, + ); + + expect(find.text('Allow file write?'), findsOneWidget); + await tester.tap(find.widgetWithText(FilledButton, 'Approve')); + await tester.pump(); + expect(actions.attentionResponses, hasLength(1)); + expect( + actions.attentionResponses.single.response.decision, + AttentionDecision.approve, + ); + }); + testWidgets('attention inbox exposes decisions and agent updates', ( tester, ) async { @@ -1582,6 +1686,9 @@ void main() { ), ); await tester.pumpAndSettle(); + // Manual compaction moved into the model selector menu. + await tester.tap(find.text('Model')); + await tester.pumpAndSettle(); expect(find.text('Compact'), findsOneWidget); await tester.tap(find.text('Compact')); await tester.pumpAndSettle(); diff --git a/apps/flutter/test/ui/shell_shortcuts_test.dart b/apps/flutter/test/ui/shell_shortcuts_test.dart new file mode 100644 index 00000000..b92f1931 --- /dev/null +++ b/apps/flutter/test/ui/shell_shortcuts_test.dart @@ -0,0 +1,120 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:t4code/src/client/app_state.dart'; +import 'package:t4code/src/host/host_profile.dart'; +import 'package:t4code/src/ui/t4_app.dart'; + +void main() { + const wideDesktop = Size(1200, 800); + + T4ViewState shellState() { + final profile = HostProfile.parseTailnetAddress( + 'https://alpha.tailnet-name.ts.net', + ); + return T4ViewState( + connectionPhase: ConnectionPhase.ready, + hostDirectory: HostDirectory.empty().upsert(profile), + authenticationPhase: AuthenticationPhase.paired, + grantedCapabilities: t4RequestedCapabilities.toSet(), + selectedSessionId: 'session-alpha', + sessions: const [ + SessionSummary( + hostId: 'host-alpha', + sessionId: 'session-alpha', + projectId: 'project-alpha', + projectName: 'Project Alpha', + title: 'Shortcut test session', + revision: 'revision-alpha', + status: 'idle', + ), + ], + messages: const [ + TranscriptMessage( + id: 'message-0', + role: MessageRole.assistant, + text: 'Hello from the transcript', + ), + ], + ); + } + + Future pressChord( + WidgetTester tester, + List keys, + ) async { + for (final key in keys) { + await tester.sendKeyDownEvent(key); + } + for (final key in keys.reversed) { + await tester.sendKeyUpEvent(key); + } + await tester.pumpAndSettle(); + } + + testWidgets( + 'mod+shift+F toggles transcript search and Escape returns to conversation', + (tester) async { + // Linux → the shell binds control as the primary modifier, which keeps + // the test free of meta-key platform quirks. + debugDefaultTargetPlatformOverride = TargetPlatform.linux; + + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = wideDesktop; + addTearDown(tester.view.reset); + + await tester.pumpWidget( + T4App( + state: shellState(), + actions: _StubActions(), + credentialsAreVolatile: false, + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Search transcripts'), findsNothing); + expect(find.text('Hello from the transcript'), findsOneWidget); + + // mod+shift+F opens the search surface. + await pressChord(tester, const [ + LogicalKeyboardKey.controlLeft, + LogicalKeyboardKey.shiftLeft, + LogicalKeyboardKey.keyF, + ]); + expect(find.text('Search transcripts'), findsWidgets); + + // Same chord toggles it back off. + await pressChord(tester, const [ + LogicalKeyboardKey.controlLeft, + LogicalKeyboardKey.shiftLeft, + LogicalKeyboardKey.keyF, + ]); + expect(find.text('Search transcripts'), findsNothing); + expect(find.text('Hello from the transcript'), findsOneWidget); + + // Reopen, then Escape returns to the conversation. + await pressChord(tester, const [ + LogicalKeyboardKey.controlLeft, + LogicalKeyboardKey.shiftLeft, + LogicalKeyboardKey.keyF, + ]); + expect(find.text('Search transcripts'), findsWidgets); + + await pressChord(tester, const [LogicalKeyboardKey.escape]); + expect(find.text('Search transcripts'), findsNothing); + expect(find.text('Hello from the transcript'), findsOneWidget); + + // Reset inside the body: the binding asserts foundation vars before + // tearDown callbacks run. + debugDefaultTargetPlatformOverride = null; + }, + ); +} + +/// Minimal stub: the shortcut flows under test never reach the host, so any +/// unexpected action call surfaces loudly as a type error instead of passing +/// silently. +final class _StubActions implements T4Actions { + @override + Object? noSuchMethod(Invocation invocation) => Future.value(); +} diff --git a/apps/flutter/web/flutter_bootstrap.js b/apps/flutter/web/flutter_bootstrap.js new file mode 100644 index 00000000..e4384b60 --- /dev/null +++ b/apps/flutter/web/flutter_bootstrap.js @@ -0,0 +1,23 @@ +// eslint-disable-next-line no-unused-expressions -- replaced by Flutter at build time +{{flutter_js}} +// eslint-disable-next-line no-unused-expressions -- replaced by Flutter at build time +{{flutter_build_config}} + +// The public demo follows current main. Remove service workers left by older +// builds before starting so they cannot keep an obsolete UI in front of the +// freshly invalidated CloudFront files. +const startT4Demo = () => _flutter.loader.load(); +if ('serviceWorker' in navigator) { + navigator.serviceWorker + .getRegistrations() + .then((registrations) => + Promise.all( + registrations + .filter((registration) => new URL(registration.scope).pathname.startsWith('/demo/')) + .map((registration) => registration.unregister()), + ), + ) + .then(startT4Demo, startT4Demo); +} else { + startT4Demo(); +} diff --git a/infra/site/cloudformation.yml b/infra/site/cloudformation.yml index 1d738f62..94f6240d 100644 --- a/infra/site/cloudformation.yml +++ b/infra/site/cloudformation.yml @@ -95,6 +95,42 @@ Resources: Override: true Protection: true + DemoResponseHeadersPolicy: + Type: AWS::CloudFront::ResponseHeadersPolicy + Properties: + ResponseHeadersPolicyConfig: + Name: t4-code-demo-security + Comment: Security headers for the Flutter web demo + CustomHeadersConfig: + Items: + - Header: Cross-Origin-Opener-Policy + Override: true + Value: same-origin + - Header: Permissions-Policy + Override: true + Value: camera=(), geolocation=(), microphone=(), payment=(), usb=() + SecurityHeadersConfig: + ContentSecurityPolicy: + ContentSecurityPolicy: "default-src 'self'; base-uri 'self'; connect-src 'self' https://fonts.gstatic.com; font-src 'self' https://fonts.gstatic.com; form-action 'self'; frame-ancestors 'none'; img-src 'self' data:; object-src 'none'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; upgrade-insecure-requests" + Override: true + ContentTypeOptions: + Override: true + FrameOptions: + FrameOption: DENY + Override: true + ReferrerPolicy: + Override: true + ReferrerPolicy: strict-origin-when-cross-origin + StrictTransportSecurity: + AccessControlMaxAgeSec: 63072000 + IncludeSubdomains: true + Override: true + Preload: true + XSSProtection: + ModeBlock: true + Override: true + Protection: true + Distribution: Type: AWS::CloudFront::Distribution Properties: @@ -114,6 +150,18 @@ Resources: ResponseHeadersPolicyId: !Ref SiteResponseHeadersPolicy TargetOriginId: SiteOrigin ViewerProtocolPolicy: redirect-to-https + CacheBehaviors: + - PathPattern: demo* + AllowedMethods: [GET, HEAD, OPTIONS] + CachedMethods: [GET, HEAD, OPTIONS] + CachePolicyId: 658327ea-f89d-4fab-a63d-7e88639e58f6 + Compress: true + FunctionAssociations: + - EventType: viewer-request + FunctionARN: !GetAtt UrlRewriteFunction.FunctionARN + ResponseHeadersPolicyId: !Ref DemoResponseHeadersPolicy + TargetOriginId: SiteOrigin + ViewerProtocolPolicy: redirect-to-https DefaultRootObject: index.html CustomErrorResponses: - ErrorCachingMinTTL: 60 diff --git a/package.json b/package.json index a66d3fab..b3f80664 100644 --- a/package.json +++ b/package.json @@ -19,10 +19,9 @@ "build:flutter:macos": "pnpm build:host && vp run --filter @t4-code/flutter build:macos", "build:flutter:android": "vp run --filter @t4-code/flutter build:android", "build:flutter:ios": "vp run --filter @t4-code/flutter build:ios", - "build:demo": "pnpm --filter @t4-code/web exec vp build --mode demo --base /demo/ --outDir ../site/dist/demo --emptyOutDir", + "build:demo": "node scripts/build-demo.mjs", "deploy:site": "node scripts/deploy-site.mjs", "deploy:demo": "node scripts/deploy-demo.mjs", - "deploy:site-bundle": "node scripts/deploy-site-bundle.mjs", "prepackage": "pnpm check && pnpm build:web && pnpm build:desktop && pnpm build:host && node scripts/package-preflight.mjs", "package:linux": "pnpm prepackage && node scripts/run-electron-builder.mjs --linux --x64", "package:mac:unsigned": "node scripts/package-mac-unsigned.mjs", diff --git a/scripts/build-demo.mjs b/scripts/build-demo.mjs new file mode 100644 index 00000000..76b188a1 --- /dev/null +++ b/scripts/build-demo.mjs @@ -0,0 +1,53 @@ +import { spawnSync } from "node:child_process"; +import { rmSync } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +export const DEMO_BASE_HREF = "/demo/"; + +function run(command, args, cwd) { + const result = spawnSync(command, args, { cwd, env: process.env, stdio: "inherit" }); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error(`${command} exited with status ${result.status ?? "unknown"}`); + } +} + +export function buildDemo( + repoRoot = resolve(import.meta.dirname, ".."), + runCommand = run, +) { + const output = resolve(repoRoot, "apps/site/dist/demo"); + runCommand( + "pnpm", + [ + "--filter", + "@t4-code/flutter", + "exec", + "flutter", + "build", + "web", + "--base-href", + DEMO_BASE_HREF, + "--csp", + "--no-web-resources-cdn", + "--dart-define", + "T4_DEMO_MODE=true", + "--output", + output, + ], + repoRoot, + ); + rmSync(resolve(output, "flutter_service_worker.js"), { force: true }); +} + +const isMain = + process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url)); +if (isMain) { + try { + buildDemo(); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } +} diff --git a/scripts/deploy-demo.mjs b/scripts/deploy-demo.mjs index 30418251..3f9c844b 100644 --- a/scripts/deploy-demo.mjs +++ b/scripts/deploy-demo.mjs @@ -1,5 +1,5 @@ import { spawnSync } from "node:child_process"; -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -14,8 +14,13 @@ function run(command, args, cwd) { } const DOCUMENT_URL_PATTERN = /\b(?:href|src)="([^"]+)"/gu; +const BASE_HREF_PATTERN = / match[1]); const localUrls = urls.filter( (url) => @@ -25,14 +30,32 @@ export function assertDemoDocumentPaths(document) { !url.startsWith("https:") && !url.startsWith("#"), ); - if (localUrls.length === 0) throw new Error("demo index does not reference local assets"); - const escaped = localUrls.find((url) => !url.startsWith("/demo/")); + if (!localUrls.includes("flutter_bootstrap.js")) { + throw new Error("demo index is not a Flutter web build"); + } + const escaped = localUrls.find((url) => { + const resolved = new URL(url, "https://t4code.net/demo/"); + return resolved.origin !== "https://t4code.net" || !resolved.pathname.startsWith("/demo/"); + }); if (escaped !== undefined) throw new Error(`demo asset escapes /demo/: ${escaped}`); } export function validateDemoBuild(repoRoot) { const document = readFileSync(resolve(repoRoot, "apps/site/dist/demo/index.html"), "utf8"); assertDemoDocumentPaths(document); + const bootstrap = readFileSync( + resolve(repoRoot, "apps/site/dist/demo/flutter_bootstrap.js"), + "utf8", + ); + if (!bootstrap.includes("registration.unregister()")) { + throw new Error("demo bootstrap must retire stale service workers"); + } + if (!bootstrap.includes('"useLocalCanvasKit":true')) { + throw new Error("demo bootstrap must use local Flutter renderer assets"); + } + if (existsSync(resolve(repoRoot, "apps/site/dist/demo/flutter_service_worker.js"))) { + throw new Error("demo build must not publish a Flutter service worker"); + } } export function deployDemo( diff --git a/scripts/deploy-demo.test.mjs b/scripts/deploy-demo.test.mjs index a619a40a..0db820af 100644 --- a/scripts/deploy-demo.test.mjs +++ b/scripts/deploy-demo.test.mjs @@ -1,8 +1,53 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { buildDemo } from "./build-demo.mjs"; import { assertDemoDocumentPaths, deployDemo } from "./deploy-demo.mjs"; -import { deploySiteBundle } from "./deploy-site-bundle.mjs"; + +test("demo build compiles the Flutter client for the /demo/ path", () => { + const calls = []; + buildDemo("/repo", (command, args, cwd) => calls.push({ command, args, cwd })); + + assert.deepEqual(calls, [ + { + command: "pnpm", + args: [ + "--filter", + "@t4-code/flutter", + "exec", + "flutter", + "build", + "web", + "--base-href", + "/demo/", + "--csp", + "--no-web-resources-cdn", + "--dart-define", + "T4_DEMO_MODE=true", + "--output", + "/repo/apps/site/dist/demo", + ], + cwd: "/repo", + }, + ]); +}); + +test("site workflow deploys the Flutter demo independently from release publication", async () => { + const { readFile } = await import("node:fs/promises"); + const workflow = await readFile(".github/workflows/deploy-site.yml", "utf8"); + const infrastructure = await readFile("infra/site/cloudformation.yml", "utf8"); + + assert.match(workflow, /- "apps\/flutter\/\*\*"/u); + assert.doesNotMatch(workflow, /- "apps\/web\/\*\*"/u); + assert.match(workflow, /demo:\n if: \$\{\{ github\.event_name == 'push' \}\}/u); + assert.match(workflow, /run: pnpm deploy:demo/u); + assert.match(workflow, /grep -Fq "'wasm-unsafe-eval'"/u); + assert.match(workflow, /run: pnpm deploy:site/u); + assert.doesNotMatch(workflow, /deploy:site-bundle/u); + assert.match(infrastructure, /PathPattern: demo\*/u); + assert.match(infrastructure, /script-src 'self' 'wasm-unsafe-eval'/u); + assert.match(infrastructure, /connect-src 'self' https:\/\/fonts\.gstatic\.com/u); +}); test("demo deploy replaces only the demo prefix after immutable assets", () => { const calls = []; @@ -31,33 +76,29 @@ test("demo deploy replaces only the demo prefix after immutable assets", () => { test("demo build keeps every local document URL under /demo", () => { assert.doesNotThrow(() => assertDemoDocumentPaths( - '', + '', ), ); assert.throws( - () => assertDemoDocumentPaths(''), + () => + assertDemoDocumentPaths( + '', + ), /demo asset escapes/u, ); - assert.throws(() => assertDemoDocumentPaths("
No assets
"), /does not reference/u); -}); - -test("site bundle preserves the demo while deploying immutable release content", () => { - const calls = []; - const config = { bucket: "t4code-net-site-595529182031", distributionId: "E1ABCDEF234567" }; - deploySiteBundle( - config, - "/release-source", - "/trusted-demo-source", - (receivedConfig, root) => calls.push({ kind: "site", config: receivedConfig, root }), - (receivedConfig, root) => calls.push({ kind: "demo", config: receivedConfig, root }), + assert.throws( + () => + assertDemoDocumentPaths( + '', + ), + /demo asset escapes/u, + ); + assert.throws( + () => assertDemoDocumentPaths(''), + /base href/u, ); - - assert.deepEqual(calls, [ - { kind: "site", config, root: "/release-source" }, - { kind: "demo", config, root: "/trusted-demo-source" }, - ]); assert.throws( - () => deploySiteBundle(config, "relative-release-source"), - /T4_IMMUTABLE_SITE_SOURCE must be an absolute path/u, + () => assertDemoDocumentPaths(''), + /not a Flutter web build/u, ); }); diff --git a/scripts/deploy-site-bundle.mjs b/scripts/deploy-site-bundle.mjs deleted file mode 100644 index df0dc5eb..00000000 --- a/scripts/deploy-site-bundle.mjs +++ /dev/null @@ -1,31 +0,0 @@ -import { isAbsolute, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -import { deployDemo } from "./deploy-demo.mjs"; -import { deploySite, resolveDeployConfig } from "./deploy-site.mjs"; - -export function deploySiteBundle( - config, - immutableSiteRoot, - demoRoot = resolve(import.meta.dirname, ".."), - deploySiteCommand = deploySite, - deployDemoCommand = deployDemo, -) { - if (!isAbsolute(immutableSiteRoot)) { - throw new Error("T4_IMMUTABLE_SITE_SOURCE must be an absolute path"); - } - deploySiteCommand(config, immutableSiteRoot); - deployDemoCommand(config, demoRoot); -} - -const isMain = - process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url)); -if (isMain) { - try { - const immutableSiteRoot = process.env.T4_IMMUTABLE_SITE_SOURCE?.trim() ?? ""; - deploySiteBundle(resolveDeployConfig(), immutableSiteRoot); - } catch (error) { - console.error(error instanceof Error ? error.message : String(error)); - process.exitCode = 1; - } -}