diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 0000000000..6b92d7b5e5 --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,42 @@ +name: android + +on: + push: + branches: + - main + paths: + - 'android/**' + - 'shared/fixtures/**' + - '.github/workflows/android.yml' + pull_request: + paths: + - 'android/**' + - 'shared/fixtures/**' + - '.github/workflows/android.yml' + +permissions: + contents: read + +concurrency: + group: android-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + defaults: + run: + working-directory: android + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + # Android SDK is preinstalled on GitHub ubuntu runners (licenses + # accepted); AGP auto-installs any missing platform/build-tools. + - uses: gradle/actions/setup-gradle@v4 + - name: Protocol tests (pure JVM, fixtures-driven) + run: ./gradlew :core:protocol:test + - name: Assemble debug APK + run: ./gradlew :app:assembleDebug diff --git a/.github/workflows/fixtures.yml b/.github/workflows/fixtures.yml new file mode 100644 index 0000000000..3fc8602caa --- /dev/null +++ b/.github/workflows/fixtures.yml @@ -0,0 +1,58 @@ +name: fixtures + +on: + push: + branches: + - main + paths: + - 'web/src/chat/**' + - 'web/src/lib/message-window-store.ts' + - 'web/src/lib/sessionPatch.ts' + - 'web/src/lib/messages.ts' + - 'shared/src/**' + - 'web/scripts/**' + - 'shared/fixtures/**' + - '.github/workflows/fixtures.yml' + pull_request: + paths: + - 'web/src/chat/**' + - 'web/src/lib/message-window-store.ts' + - 'web/src/lib/sessionPatch.ts' + - 'web/src/lib/messages.ts' + - 'shared/src/**' + - 'web/scripts/**' + - 'shared/fixtures/**' + - '.github/workflows/fixtures.yml' + +permissions: + contents: read + +concurrency: + group: fixtures-${{ github.ref }} + cancel-in-progress: true + +jobs: + drift-gate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.14 + - run: bun install + - name: Regenerate fixtures from the web pipeline + run: bun run gen:fixtures + - name: Fail on fixtures drift + id: drift + # --intent-to-add makes brand-new fixture files (new cases) visible + # to git diff; deletions of stale files are visible either way. + run: | + git add --intent-to-add shared/fixtures + git diff --exit-code -- shared/fixtures + - name: Explain drift + if: failure() && steps.drift.conclusion == 'failure' + run: echo '::error::web pipeline behavior changed — run `bun run gen:fixtures` and commit the updated fixtures so native ports see the diff' + - name: Web self-conformance (fixture-related tests) + # src/lib/sessionPatch matches nothing until K7 lands its tests; + # vitest unions filters, so the extra filter is future-proof, not fatal. + run: cd web && bun run test -- src/chat/fixtures.test.ts src/lib/message-window-store.test.ts src/lib/messages.test.ts src/lib/sessionPatch diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml new file mode 100644 index 0000000000..9e26415ae3 --- /dev/null +++ b/.github/workflows/ios.yml @@ -0,0 +1,40 @@ +name: ios + +on: + push: + branches: + - main + paths: + - 'ios/**' + - 'shared/fixtures/**' + - '.github/workflows/ios.yml' + pull_request: + paths: + - 'ios/**' + - 'shared/fixtures/**' + - '.github/workflows/ios.yml' + +concurrency: + group: ios-${{ github.ref }} + cancel-in-progress: true + +jobs: + package-tests: + runs-on: macos-15 + steps: + - uses: actions/checkout@v4 + - name: Show toolchain versions + run: | + xcodebuild -version + swift --version + - name: Run HapiKit package tests + run: swift test --package-path ios/Packages/HapiKit + + app-build: + runs-on: macos-15 + steps: + - uses: actions/checkout@v4 + - name: Show toolchain versions + run: xcodebuild -version + - name: Build Hapi app for iOS Simulator + run: xcodebuild build -project ios/Hapi.xcodeproj -scheme Hapi -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO diff --git a/.gitignore b/.gitignore index 7c12eb7360..1114b5e282 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,7 @@ coverage/ # Claude local settings .claude/settings.local.json +.claude/worktrees/ localdocs/ execplan/ @@ -50,3 +51,26 @@ e2e-output/ .xyz-harness .agents/ .pi/ + +# Xcode / iOS build artifacts +ios/**/xcuserdata/ +# Standalone projects recreate this workspace wrapper; keep Package.resolved trackable. +ios/**/*.xcodeproj/project.xcworkspace/contents.xcworkspacedata +ios/**/.build/ +ios/**/.swiftpm/ +**/*.xcresult +**/DerivedData/ + +# Android (android/) +android/**/build/ +android/.gradle/ +android/.kotlin/ +android/local.properties +android/captures/ +*.keystore +*.jks + +# Firebase config is per-operator (official builds inject via CI; self-builds +# drop in their own) — never commit a real one. The committed placeholder is +# android/app/google-services.json.example. +android/app/google-services.json diff --git a/AGENTS.md b/AGENTS.md index dd7eb586d1..4bf96f135d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,15 +11,18 @@ Local-first platform for running AI coding agents (Claude Code, Codex, Gemini) w ## Repo layout ``` -cli/ - CLI binary, agent wrappers, runner daemon -hub/ - HTTP API + Socket.IO + SSE + Telegram bot -web/ - React PWA for remote control -shared/ - Common types, schemas, utilities -docs/ - VitePress documentation site -website/ - Marketing site +cli/ - CLI binary, agent wrappers, runner daemon +hub/ - HTTP API + Socket.IO + SSE + Telegram bot +web/ - React PWA for remote control +ios/ - Native SwiftUI app (in development) +android/ - Native Kotlin Compose app (in development) +shared/ - Common types, schemas, utilities +shared/fixtures/ - Golden chat fixtures, generated from web pipeline (never hand-edit) +docs/ - VitePress documentation site +website/ - Marketing site ``` -Bun workspaces; `shared` consumed by cli, hub, web. +Bun workspaces; `shared` consumed by cli, hub, web. `ios`/`android` outside workspaces (Xcode / Gradle toolchains). ## Architecture overview @@ -64,11 +67,15 @@ Bun workspaces; `shared` consumed by cli, hub, web. ```bash bun typecheck # All packages -bun run test # cli + hub tests +bun run test # cli + hub + web + shared tests bun run dev # hub + web concurrently bun run build:single-exe # All-in-one binary +bun run gen:fixtures # Regenerate shared/fixtures/ from web pipeline +cd android && ./gradlew :core:protocol:test # Android protocol conformance ``` +iOS tests run in CI (`ios.yml`: macOS `swift test`); no local Xcode/Swift toolchain assumed. + ## Key source dirs ### CLI (`cli/src/`) @@ -109,6 +116,22 @@ bun run build:single-exe # All-in-one binary - `messages.ts` - Message parsing utilities - `modes.ts` - Permission/model mode definitions +### iOS (`ios/`) +- `Packages/HapiKit/` - local SPM package: `HapiProtocol` (wire models + chat pipeline, fixtures-verified), `HapiClient` (API/auth/SSE/stores) +- `Hapi/` + `Hapi.xcodeproj` - thin SwiftUI app target + +### Android (`android/`) +- `:core:protocol` - pure JVM wire types + chat pipeline (fixtures-verified) +- `:core:data` - transport (OkHttp/SSE), auth, stores +- `:app` - Compose UI, navigation, deep links, FCM + +## Protocol conformance (native apps) + +- `shared/fixtures/**` machine-generated from the web chat pipeline (source of truth). NEVER hand-edit; edit `web/scripts/fixtures/cases/` + regenerate. +- Changing `web/src/chat/**`, `web/src/lib/message-window-store.ts`, or `web/src/lib/sessionPatch.ts`: run `bun run gen:fixtures`, commit the diff. CI enforces (`.github/workflows/fixtures.yml`); fixture diffs auto-trigger iOS/Android conformance suites (`ios.yml`/`android.yml`). +- Native client contract docs: `docs/api/client-contract/` (auth, rest, sse, pagination, messages, errors). +- Tracks: `ios/` (SwiftUI, iOS 17+) + `android/` (Kotlin Compose, minSdk 26) — independent codebases, share only contract + fixtures. Plan: `~/.claude/plans/web-pwa-abundant-yeti.md`. + ## Pre-push self-review (agents) Before commit/push/PR: use the **`pre-push-review`** skill (`~/.cursor/skills/pre-push-review/`). @@ -124,7 +147,7 @@ Before commit/push/PR: use the **`pre-push-review`** skill (`~/.cursor/skills/pr - Run: `bun run test` (from root) or `bun run test` (from package) - Hub tests: `hub/src/**/*.test.ts` - CLI tests: `cli/src/**/*.test.ts` -- No web tests currently +- Web tests: `web/src/**/*.test.{ts,tsx}` (fixtures self-check: `web/src/chat/fixtures.test.ts`) ## Common tasks @@ -139,6 +162,7 @@ Before commit/push/PR: use the **`pre-push-review`** skill (`~/.cursor/skills/pr | Modify message handling | `hub/src/sync/messageService.ts` | | Add notification type | `hub/src/notifications/` | | Add shared type | `shared/src/types.ts`, `shared/src/schemas.ts` | +| Attach outliving job | `cli/src/commands/job.ts`, `docs/guide/session-jobs.md` | ## Important patterns @@ -148,6 +172,20 @@ Before commit/push/PR: use the **`pre-push-review`** skill (`~/.cursor/skills/pr - **Permission modes**: `default`, `acceptEdits`, `auto`, `bypassPermissions`, `plan` - **Namespaces**: Multi-user isolation via `CLI_API_TOKEN:` suffix +## Session-attached jobs (outliving work) + +When an agent starts process-shaped work that will keep running after the agent goes idle (`nohup`, batch imports, long scripts, external daemons), attach it so the session list stays truthful while `active: false`. This is **not** thinking progress / todos / in-agent background tools. It is also **not** an A2A Layer 1 `work_ad` ([#1332](https://github.com/tiann/hapi/discussions/1332)) — jobs enrich Layer 0 `SessionSummary`; leave collaboration claims / handoffs to the work-graph ledger. + +Agent contract (idle agents cannot heartbeat — bare set + nohup freezes the bar): + +1. **Required for process-shaped work:** Shell `hapi job run --label … -- ` (auto-heartbeat + exit status). Use `"$HAPI_SESSION_ID"` only when it matches the operator chat row (`/sessions/` in the web URL); for remote Cursor runner, pass that URL UUID explicitly (shell env can point at a worker row — see `docs/guide/session-jobs.md`). +2. MCP `session_job` **refuses `action=set`**. Use it only for `update` / `clear` / `list` on a job the supervisor already created +3. Manual CLI `set` only with a self-heartbeating wrapper (`update` ≥~10m); never MCP set + nohup +4. Prefer honest `--remaining` or `--done`/`--total`; omit counts if unknown — never invent a percent +5. Elapsed wall clock is always shown from `startedAt` (not an ETA); correct late attach with CLI `set --started-at` (or clear+set) + +Full guide: `docs/guide/session-jobs.md`. CLI: `hapi job --help`. + ## Adding new web features — consider an FUE When you ship a non-essential feature (the 20% of sessions, not the 80%), consider wrapping its affordance in the generic First-User-Experience primitive so existing users discover it without a giant always-visible UI block. diff --git a/README.md b/README.md index 206694e014..13eda0f9ba 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,10 @@ For self-hosted options (Cloudflare Tunnel, Tailscale), see [Installation](docs/ - [Why HAPI](docs/guide/why-hapi.md) - [FAQ](docs/guide/faq.md) +## Native apps (iOS / Android) + +Fully native SwiftUI and Kotlin Compose clients are in development under `ios/` and `android/`. They pair with your hub by scanning the same terminal QR code as the web app, and follow the same protocol — see the [client contract docs](docs/api/client-contract/index.md). + ## Build from source ```bash diff --git a/android/README.md b/android/README.md new file mode 100644 index 0000000000..910ff75240 --- /dev/null +++ b/android/README.md @@ -0,0 +1,197 @@ +# HAPI Android Companion + +Native Android client (Kotlin + Jetpack Compose) for the HAPI hub. Fully +independent from the web app; shares only the protocol contract +(`docs/api/`) and the golden fixtures (`shared/fixtures/`). + +- **applicationId**: `run.hapi.companion` · **minSdk** 26 · **target/compileSdk** 36 +- **Toolchain**: Gradle 8.14.2 (wrapper) · AGP 8.11.1 · Kotlin 2.1.21 · Compose BOM 2025.05.00 · JDK 17+ (CI uses 21) + +## Modules + +| Module | Type | Responsibility | +|---|---|---| +| `:core:protocol` | **pure Kotlin/JVM** (no Android) | Hub wire types (kotlinx.serialization), chat pipeline port (normalize → reduce → tool groups), message-window/pagination logic, versioned patch application, modes catalog, git output parsers, `BindLink` pairing-link parsing. **M1a landed**: `wire/` (`HapiJson`, `Session`/`SessionPatch`/`SessionSummary`, `DecryptedMessage`, `AgentState`, `Machine`, 13-type `SyncEvent` union via `SyncEvents.parse`, `MessagesResponse`), `catalog/` (flavors + permission/collaboration modes), `patch/SessionPatching.kt` (exact port of `web/src/lib/sessionPatch.ts`), all fixture-verified. | +| `:core:data` | Android library | Transport + persistence. **M1b landed** — `auth/` (`JwtPeek`, `CredentialStore` interface + `EncryptedPrefsCredentialStore`/in-memory, `HubUrls` origin normalization, `HubRegistry` roster behind a storage seam, `AuthInterceptor` + single-flight `TokenAuthenticator` with `ensureFreshToken()` and terminal `AuthEvents`), `api/` (`HapiApi` — plain OkHttp + kotlinx.serialization, one suspend fun per v1 endpoint incl. generated-image bytes via a 256 MB OkHttp cache and the multipart transcription helper; `ApiError` with `(status, code)`), `HubSession` per-hub factory; MockWebServer-tested. **M1c landed** — `sse/`: `SseEngine` (per-key `global`/`session:` loops, `connection-changed` handshake gate with `ok`/`gap` resume verdict, per-key `Last-Event-ID` cursors advanced only after downstream hand-off (at-least-once), 10 s connect deadline, 90 s watchdog, 1 s→30 s→300 s backoff + jitter, background retry deferral + 45 s foreground stale check, one silent 401 re-auth per cycle), `OkHttpSseTransport` (dedicated client, `readTimeout=0`, incremental gzip decoding pinned by test, `acceptEncodingIdentity` fallback), `SyncEventRouter` → `SyncTargets` seam; virtual-time tested. Still to come: StateFlow stores + AtomicFile JSON snapshots (M2), FCM registration + WorkManager workers (M4). | +| `:app` | Android application | Compose UI, navigation, deep links (`hapicompanion://bind`), FCM service (M4), hand-rolled DI (`AppGraph`, no Hilt). **M1d landed** — `di/` (`AppGraph` process singletons: Preferences DataStore-backed `HubRegistryStorage`, `EncryptedPrefsCredentialStore`, `HubRegistry`, auth-terminal fan-out; `HubGraph` per active hub: `HubSession` + `SseEngine` wired to `ensureFreshToken`, recreated on hub switch; `LocalAppGraph` CompositionLocal + `viewModelFactory` helper), `feature/pairing/` (landing / zxing `ScanContract` QR scan / manual entry sharing one `PairingViewModel`: health + protocol check → `POST /api/auth` → persist + activate), `feature/home/` placeholder (hub switcher + sign-out), `Navigation.kt` (pairing ⇄ home, auth-terminal → pairing with banner), bind deep-link handling in `MainActivity`. | + +Dependency direction: `:app` → `:core:data` → `:core:protocol`. + +## Protocol conformance fixtures + +`:core:protocol` is the porting target for `web/src/chat/` and is verified +against golden fixtures generated from the web implementation (track K). +The test task already passes the fixtures location as a system property: + +```kotlin +// core/protocol/build.gradle.kts +tasks.test { + systemProperty("hapi.fixtures.dir", rootDir.parentFile.resolve("shared/fixtures").absolutePath) +} +``` + +Fixture-driven tests (M2) read `System.getProperty("hapi.fixtures.dir")` — +no further build changes are needed when `shared/fixtures/**` lands. CI +re-runs this suite whenever `android/**` or `shared/fixtures/**` change. + +## Building + +Requires an Android SDK for `:app`/`:core:data` (set `ANDROID_HOME` or +`android/local.properties` with `sdk.dir=...`). `:core:protocol` alone needs +only a JDK. + +```sh +cd android +./gradlew :core:protocol:test # pure JVM protocol tests (fast) +./gradlew :app:assembleDebug # debug APK +./gradlew :app:installDebug # install on a connected device +``` + +Without an Android SDK you can still run the protocol suite by configuring +only the needed projects: + +```sh +./gradlew --no-configuration-cache --configure-on-demand :core:protocol:test +``` + +CI (`.github/workflows/android.yml`) runs the protocol tests and +`:app:assembleDebug` on every PR touching `android/**` or `shared/fixtures/**`. + +## Pairing + +HAPI is self-hosted: the app talks to a hub **you** run. Pairing = giving the +app a hub URL plus that hub's access token; the app verifies the hub +(`GET /health`, protocol version), exchanges the token for a JWT +(`POST /api/auth`), stores the credentials in `EncryptedSharedPreferences` +(keyed per hub — multiple hubs can be paired, one active at a time), and +lands on the session UI. Three entry points: + +1. **QR scan** — the hub prints two QR codes when started with `--relay` + (also under web Settings → Companion pairing). The in-app scanner accepts + both: the companion deeplink (`hapicompanion://bind?hub=…&code=…`) and the + web direct-access URL (`…?hub=…&token=…`). +2. **Deep link** — scanning the companion QR with the system camera opens the + app directly with a confirm screen (`hapicompanion://bind` intent filter). +3. **Manual entry** — hub URL + access token, for hubs started without + `--relay`. + +### Pairing against a local dev hub + +```sh +# repo root: start the hub (prints the access token + QR codes) +bun run dev + +# emulator: the host machine is 10.0.2.2 +# Hub URL: http://10.0.2.2:3006 +# Access token: from the hub terminal / hub settings.json (CLI_API_TOKEN) +# physical device: use the machine's LAN IP, e.g. http://192.168.1.10:3006 +adb shell am start -a android.intent.action.VIEW \ + -d "hapicompanion://bind?hub=http%3A%2F%2F10.0.2.2%3A3006&code=" # optional: exercises the deep link +``` + +Plain-`http` LAN/emulator hubs work in all build types: the manifest opts in +to cleartext traffic (`android:usesCleartextTraffic="true"`), because +self-hosted LAN hubs are the primary pairing target and Android cannot scope +the exemption to local addresses only. Sign-out (home → Sign out) deletes the +stored credentials for that hub and drops it from the roster. + +## Milestones (track B of the native-clients plan) + +- **M0** — this scaffold: modules, version catalog, CI, placeholder screen. +- **M1** — foundations: wire types + modes catalog; auth + `HapiApi` (MockWebServer-tested); `SseEngine` reconnect state machine + versioned patches (gzip streaming verified); pairing UI + `hapicompanion://bind` deep link. +- **M2** — read-only chat: chat pipeline port gated on fixtures all-green; session list; `MessageWindowStore` port; Markdown renderer; read-only chat screen (`LazyColumn(reverseLayout = true)`). +- **M3** — interaction: composer (optimistic send/queue/steer/drafts), permission approvals UX, session controls (mode/model/abort/resume/rename/archive), new session, dictation. + - **B-M3ce landed** — voice dictation: mic button in the composer (`RECORD_AUDIO` requested at first use), `MediaRecorder` → m4a/AAC, provider discovery via `GET /api/voice/transcription/providers` (first `standard`-capable provider; a hub without one shows a notice), upload through the multipart `POST /api/voice/transcription`, transcript appended at the composer text with a space separator; `DictationController` is a plain seam over recorder + API, JVM-tested with fakes. Slash commands: typing a lone `/token` opens a dropdown merging the session's `metadata.slashCommands` names with the `GET /slash-commands` RPC list (RPC entries win dedupe; exact → prefix → contains filtering), tap inserts `/name ` (the skills `$` trigger is deferred). Session ops: list long-press sheet and chat top-bar overflow gain Rename (`PATCH /sessions/:id`, optimistic name with roll-forward on failure), Delete (confirm; 409-while-active surfaced), and Reopen for inactive sessions (`POST /reopen`; a superseding id reuses the supersede path — window seed + draft move + navigate-replace; 422 missing-metadata formatted); chat shows an inactive-session bar ("send to resume, or Reopen"). +- **M4** — FCM push (register → notification actions via expedited WorkManager) + files/git viewer, Scratchlist, usage/storage stats. + - **B-M4a landed** — FCM push + notification actions. `:core:data` `push/`: `PushPayload` (data-only contract v1 decoding: type/severity/`notifySummary` parsing, channel routing, `type-` coalescing tags, unknown type/contractVersion degrade to plain title/body), `DeviceRegistrar` (registers the FCM token with **every** paired hub on start/pairing/`onNewToken`, DataStore-persisted `deviceId` UUID, WorkManager retry seam, best-effort unregister on sign-out before credentials are wiped), `PushHubAccess` + `PushActionRunner` (workers build a `HubSession` on demand from stored credentials — no `HubGraph` needed in background — and resolve the owning hub: active hub first, other paired hubs on 404 session-miss). `:app`: `push/PushBinding` (Firebase availability gate — no `google-services.json` → all push paths no-op), `fcm/` (`HapiFirebaseMessagingService`, `NotificationChannels` — `permission_requests` HIGH / `ready` / `task_notifications`, `PushNotifications` builder with severity accents + suppress-when-open rule, `NotificationActionReceiver` → expedited `PermissionActionWorker` (Allow/Deny → approve/deny `{}`) and `SendMessageWorker` (RemoteInput reply → `{text, localId}`) with pending → done/"Already handled"/failed notification states), WorkManager on-demand init + `HapiWorkerFactory`, notification tap → internal `MainActivity` intent route → chat. +- **M5** — polish: zh-CN i18n, OLED/Material You theming, predictive back, LeakCanary pass, Play listing + self-build docs. + - **B-M5a landed** — zh-CN localization + in-app language switching (see "Internationalization" below). + +## Internationalization (B-M5a) + +The app ships English (default) and Simplified Chinese +(`app/src/main/res/values-zh-rCN/strings.xml`). Every user-visible string +lives in resources; both files carry the **same key set** (lint +`MissingTranslation` is the gate). + +**Adding a string** + +1. Add it to `app/src/main/res/values/strings.xml` with a feature-prefixed + key matching the existing convention (`chat_`, `sessions_`, `files_`, + `scratchlist_`, `pairing_`, `settings_`, `new_session_`, `notif_`, + `tool_` for tool-card titles). Dynamic values use positional format args + (`%1$s`, `%2$d`); count-dependent copy uses explicit `_one`/`_many` keys + (the deliberate house style — no ``). +2. Add the zh-CN twin to `values-zh-rCN/strings.xml`. **Terminology source of + truth is the web corpus** `web/src/lib/locales/zh-CN.ts` — reuse its + product terms (会话 session, 机器 machine, 权限模式 permission mode, + 工作树 worktree, 草稿夹 scratchlist, 语音输入 dictation, 用量 usage, + 智能体/代理 agent). Technical identifiers (model ids, flavor names like + Claude/Codex, permission-mode catalog labels, CLI flags) stay + untranslated, matching the web's choices. +3. Reference it: composables via `stringResource(R.string...)`. ViewModels + stay string-free — transient notices are **semantic sealed types** + (`ChatNotice`, `ScratchlistNotice`, `PairingError`, `DictationErrorKind`) + resolved at the UI layer; where a ViewModel genuinely composes display + text it takes a small Strings seam (`FilesStrings`, `FileViewerStrings`, + `NewSessionStrings`) whose defaults are the English values (JVM tests + construct without arguments) and whose production instance is + resource-resolved in the Navigation holders. Server-provided error text + passes through verbatim. + +**Language switching** + +`Settings → App language` offers Follow system (default) / English / +简体中文. The choice persists in `LanguagePrefs` (DataStore) and applies +immediately via `AppCompatDelegate.setApplicationLocales`: + +- `MainActivity` extends `AppCompatActivity` (theme parent + `Theme.AppCompat.DayNight.NoActionBar`) so per-app locales work back to + API 26; on API 33+ the framework `LocaleManager` takes over (the app also + declares `android:localeConfig` for the system App-languages screen). +- The manifest opts into appcompat's `autoStoreLocales` + (`AppLocalesMetadataHolderService` meta-data), which re-applies the stored + choice synchronously on cold start. +- Surfaces that resolve strings from the **application** context — FCM + notifications, WorkManager result updates, notification-action receivers — + wrap their context with `localizedForAppLanguage(AppGraph.appLanguage)` + (`di/LocaleContexts.kt`), since per-app locales only retarget activity + contexts below API 33. + +Out of scope on purpose: `:core:protocol` presentation strings +(`getEventPresentation`, tool-group activity titles) stay English — the web +does not translate them either, and terminology parity with the web wins. + +## Firebase / push + +FCM needs a Firebase project binding, which is deliberately **optional**: +the `com.google.gms.google-services` plugin is applied *conditionally* +(only when `app/google-services.json` exists — see `app/build.gradle.kts`), +so the repo always builds green without any Firebase config. Without one, +`FirebaseApp` never initializes, `PushBinding.isAvailable` reports false, +and every push code path (registration, FCM service, workers, the +notification-permission prompt) no-ops — the app behaves like pre-M4a. + +To enable push: + +1. **Official builds**: CI injects the default Firebase project's + `google-services.json` before assembling (the file is gitignored; + `app/google-services.json.example` documents the expected shape). +2. **Self-builds**: create your own Firebase project, add an Android app + with your `applicationId` (default `run.hapi.companion`), download + `google-services.json` into `android/app/`, and rebuild. +3. **Hub side**: point the hub at the *same* Firebase project — + `FCM_SERVICE_ACCOUNT_PATH` + `FCM_PROJECT_ID` + (`docs/api/native-companion-contract.md`). The device registers itself + with every paired hub (`POST /api/devices/register`) on pairing, app + start, and token rotation, and unregisters on sign-out. + +Multi-hub note: the FCM payload does not name the sending hub (contract v1), +so notification actions resolve it — the workers try the **active** hub +first, then the other paired hubs when a hub answers 404 for the session. +Single-hub setups always hit on the first try. Tapping a notification opens +the session against the active hub. + +Planned for v1.x: runtime `FirebaseOptions` handed out by the hub, so +self-builds get push without baking a config into the APK. That lands +entirely behind the existing `app/.../push/PushBinding.kt` seam. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000000..66fbd1f75b --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,128 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + // New-session form draft + prefs persist as JSON blobs in DataStore (B-M3d). + alias(libs.plugins.kotlin.serialization) + // com.google.gms.google-services is applied CONDITIONALLY below (B-M4a): + // the plugin hard-fails when app/google-services.json is missing, and the + // repo must build green without any Firebase project configured. +} + +// FCM (B-M4a): official builds inject google-services.json in CI; self-builders +// drop in their own (see ../README.md "Firebase / push"); everyone else builds +// without it — Firebase then never initializes and PushBinding reports push as +// unavailable, so every push code path no-ops cleanly. +if (file("google-services.json").exists()) { + apply(plugin = "com.google.gms.google-services") +} + +android { + namespace = "app.hapi.companion" + compileSdk = 36 + + defaultConfig { + applicationId = "run.hapi.companion" + minSdk = 26 + targetSdk = 36 + versionCode = 1 + versionName = "0.1.0" + } + + buildTypes { + release { + isMinifyEnabled = true + isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + buildFeatures { + compose = true + // About screen surfaces BuildConfig.VERSION_NAME (B-M4e). + buildConfig = true + } + + testOptions { + unitTests.all { test -> + // Chat pipeline smoke tests replay golden fixtures from the repo + // root (same wiring as :core:protocol / :core:data). + test.systemProperty( + "hapi.fixtures.dir", + rootDir.parentFile.resolve("shared/fixtures").absolutePath, + ) + } + } +} + +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } +} + +dependencies { + implementation(project(":core:protocol")) + implementation(project(":core:data")) + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.activity.compose) + // Per-app locales (B-M5a): AppCompatActivity + setApplicationLocales + // (autoStoreLocales service is declared in the manifest). + implementation(libs.androidx.appcompat) + implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.lifecycle.process) + implementation(libs.androidx.datastore.preferences) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.kotlinx.serialization.json) + + // QR pairing (ScanContract activity-result API; no Play Services). + implementation(libs.zxing.android.embedded) + + // Generated images (chat, B-M2d2): loader wired in HubGraph over the + // authed + disk-cached hub image client. + implementation(libs.coil.compose) + + // FCM push + notification actions (B-M4a). firebase-messaging is always on + // the classpath; whether it *activates* depends on google-services.json + // (conditional plugin above) — PushBinding gates every use at runtime. + implementation(libs.firebase.messaging) + implementation(libs.androidx.work.runtime.ktx) + constraints { + // Version floor only: firebase-messaging → play-services-base drags in + // androidx.fragment 1.1.0, whose broken permission-result routing makes + // lintVital reject any ActivityResult use (MainActivity's + // POST_NOTIFICATIONS prompt). Nothing in the app uses fragments. + implementation(libs.androidx.fragment) + } + + // Markdown rendering (B-M2d1). commonmark comes through :core:protocol's + // `api` too; declared here because ui/markdown walks the AST types directly. + implementation(libs.commonmark) + implementation(libs.commonmark.ext.gfm.tables) + implementation(libs.commonmark.ext.gfm.strikethrough) + implementation(libs.highlights) + + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.ui.tooling.preview) + debugImplementation(libs.androidx.compose.ui.tooling) + + // JVM unit tests (ViewModel combine logic with fake stores). + testImplementation(libs.junit) + testImplementation(libs.kotlin.test) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.turbine) +} diff --git a/android/app/google-services.json.example b/android/app/google-services.json.example new file mode 100644 index 0000000000..e9f0658009 --- /dev/null +++ b/android/app/google-services.json.example @@ -0,0 +1,41 @@ +{ + "_comment": [ + "Placeholder Firebase config (B-M4a). To enable FCM push in a self-build:", + "1. Create a Firebase project (console.firebase.google.com) and add an", + " Android app with package name `run.hapi.companion` (or your fork's", + " applicationId — keep app/build.gradle.kts in sync).", + "2. Download the real google-services.json from the Firebase console and", + " save it as android/app/google-services.json (gitignored).", + "3. Configure the SAME Firebase project on your hub:", + " FCM_SERVICE_ACCOUNT_PATH + FCM_PROJECT_ID (docs/api/native-companion-contract.md).", + "Without the file the app still builds and runs — push is simply reported", + "unavailable (PushBinding) and every push code path no-ops." + ], + "project_info": { + "project_number": "000000000000", + "project_id": "your-firebase-project-id", + "storage_bucket": "your-firebase-project-id.appspot.com" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:000000000000:android:0000000000000000000000", + "android_client_info": { + "package_name": "run.hapi.companion" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSy-your-android-api-key" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + "configuration_version": "1" +} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000000..dcccef4df1 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,3 @@ +# App-specific R8/ProGuard rules. +# kotlinx.serialization ships its own consumer rules; add rules here only when a +# release build actually needs them (verified via :app:assembleRelease + smoke test). diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..896fdde0f5 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,147 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/app/hapi/companion/HapiApp.kt b/android/app/src/main/kotlin/app/hapi/companion/HapiApp.kt new file mode 100644 index 0000000000..5c2e80acec --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/HapiApp.kt @@ -0,0 +1,54 @@ +package app.hapi.companion + +import android.app.Application +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ProcessLifecycleOwner +import androidx.work.Configuration +import app.hapi.companion.di.AppGraph +import app.hapi.companion.di.HapiWorkerFactory +import app.hapi.companion.fcm.NotificationChannels + +/** + * Owns the process-singleton [AppGraph]. Compose reads it through + * [app.hapi.companion.di.LocalAppGraph]; non-Compose entry points (FCM + * service, WorkManager workers) reach it via `(context.applicationContext as + * HapiApp).appGraph`. + * + * Also bridges [ProcessLifecycleOwner] into the graph (B-M3ab): foreground / + * background drives `SseEngine.setLifecycleForeground` (retry deferral, + * stale-socket rebuild on resume) and `POST /api/visibility` reporting so the + * hub can suppress redundant push while the app is visibly connected. + * + * Push (B-M4a): notification channels are created here so they exist before + * the first FCM message, and WorkManager is switched to on-demand + * initialization (manifest removes the default initializer) with + * [HapiWorkerFactory] — the push workers need [AppGraph], which this class + * guarantees exists first even when WorkManager cold-starts the process. + */ +class HapiApp : Application(), Configuration.Provider { + + lateinit var appGraph: AppGraph + private set + + override fun onCreate() { + super.onCreate() + appGraph = AppGraph(this) + appGraph.start() + NotificationChannels.ensureCreated(this) + ProcessLifecycleOwner.get().lifecycle.addObserver(object : DefaultLifecycleObserver { + override fun onStart(owner: LifecycleOwner) { + appGraph.setForeground(true) + } + + override fun onStop(owner: LifecycleOwner) { + appGraph.setForeground(false) + } + }) + } + + override val workManagerConfiguration: Configuration + get() = Configuration.Builder() + .setWorkerFactory(HapiWorkerFactory { appGraph }) + .build() +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/MainActivity.kt b/android/app/src/main/kotlin/app/hapi/companion/MainActivity.kt new file mode 100644 index 0000000000..cf4e5f46cc --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/MainActivity.kt @@ -0,0 +1,140 @@ +package app.hapi.companion + +import android.Manifest +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import android.os.Bundle +import android.view.WindowManager +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.appcompat.app.AppCompatActivity +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.lifecycle.lifecycleScope +import app.hapi.companion.di.AppGraph +import app.hapi.companion.di.LocalAppGraph +import app.hapi.companion.fcm.PushNotifications +import app.hapi.companion.feature.settings.ThemeMode +import app.hapi.companion.feature.settings.ThemeSettings +import app.hapi.companion.push.PushBinding +import app.hapi.companion.ui.theme.HapiTheme +import app.hapi.protocol.pairing.BindLink +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch + +/** + * Single-activity entry point (`launchMode="singleTask"`). Hosts the + * Navigation Compose graph under the persisted theme choice + * ([AppGraph.themePrefs], B-M4e) and feeds two intent routes — cold start and + * [onNewIntent] — into [AppGraph] flows the navigation layer consumes: + * + * - `hapicompanion://bind?hub=…&code=…` pairing deep links (parsing stays in + * [BindLink]) → [AppGraph.pendingBindLink]; + * - the **internal** notification-tap route ([PushNotifications.ACTION_OPEN_SESSION] + * + session-id extra, B-M4a — explicit intent, deliberately no public URI) + * → [AppGraph.pendingOpenSessionId]. + */ +// AppCompatActivity (not ComponentActivity) since B-M5a: the appcompat base +// class is what applies AppCompatDelegate.setApplicationLocales on API < 33 +// (per-app language switching); on API 33+ the framework handles it. +class MainActivity : AppCompatActivity() { + + private val appGraph: AppGraph get() = (application as HapiApp).appGraph + + private val notificationPermissionRequest = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { + // Denial is respected silently: pushes still arrive and update in-app + // state via SSE; only the OS notification surface stays quiet. + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + if (Build.VERSION.SDK_INT >= 30) { + // Compose's imePadding is the single keyboard-inset owner. With the + // default adjust mode, the AppCompat subdecor ALSO resizes the + // window for the IME (device-observed: a keyboard-sized gap above + // the keyboard). ADJUST_NOTHING kills the legacy resize; IME + // insets are always delivered on 30+ regardless of soft-input + // mode. On 26–29 the ime() backport requires adjustResize, so + // those keep the default (and tolerate the legacy behavior). + @Suppress("DEPRECATION") + window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING) + } + if (savedInstanceState == null) { + // Only a fresh launch consumes the launching intent — after a + // config change / process restore the same (already-consumed) + // intent is redelivered and must not resurrect the confirm card + // (or re-trigger a notification navigation). + handleBindIntent(intent) + handleOpenSessionIntent(intent) + } + setContent { + // Follow-system default renders for the first frames while the + // DataStore read completes; the persisted choice then applies. + val theme by appGraph.themePrefs.settings.collectAsState(initial = ThemeSettings()) + HapiTheme( + darkTheme = when (theme.mode) { + ThemeMode.SYSTEM -> isSystemInDarkTheme() + ThemeMode.LIGHT -> false + ThemeMode.DARK, ThemeMode.OLED -> true + }, + dynamicColor = theme.dynamicColor, + oled = theme.mode == ThemeMode.OLED, + ) { + CompositionLocalProvider(LocalAppGraph provides appGraph) { + HapiNavigation() + } + } + } + maybeRequestNotificationPermission() + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + handleBindIntent(intent) + handleOpenSessionIntent(intent) + } + + private fun handleBindIntent(intent: Intent?) { + if (intent?.action != Intent.ACTION_VIEW) return + val data = intent.data ?: return + val link = BindLink.parse(data.toString()) + if (link != null) { + appGraph.pendingBindLink.value = link + } else if (BindLink.SCHEME.equals(data.scheme, ignoreCase = true)) { + // Ours but malformed (truncated QR, mangled copy/paste). + appGraph.pairingNotice.value = getString(R.string.pairing_invalid_link) + } + } + + /** Notification tap: stash the target session for `HapiNavigation`. */ + private fun handleOpenSessionIntent(intent: Intent?) { + if (intent?.action != PushNotifications.ACTION_OPEN_SESSION) return + val sessionId = intent.getStringExtra(PushNotifications.EXTRA_SESSION_ID) ?: return + appGraph.pendingOpenSessionId.value = sessionId + } + + /** + * POST_NOTIFICATIONS (API 33+) — asked only once a hub is actually + * paired (never on the pristine first open) and only when push can work + * at all ([PushBinding.isAvailable]). Waiting on the roster flow means a + * fresh pairing in this very session triggers the prompt right away. + */ + private fun maybeRequestNotificationPermission() { + if (Build.VERSION.SDK_INT < 33) return + if (!PushBinding.isAvailable(this)) return + lifecycleScope.launch { + appGraph.hubRegistry.state.first { it.hubs.isNotEmpty() } + val granted = checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) == + PackageManager.PERMISSION_GRANTED + if (!granted) { + notificationPermissionRequest.launch(Manifest.permission.POST_NOTIFICATIONS) + } + } + } +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/Navigation.kt b/android/app/src/main/kotlin/app/hapi/companion/Navigation.kt new file mode 100644 index 0000000000..36778cea2b --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/Navigation.kt @@ -0,0 +1,833 @@ +package app.hapi.companion + +import android.content.Context +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavDestination.Companion.hierarchy +import androidx.navigation.NavHostController +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.navigation +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument +import app.hapi.companion.di.AppGraph +import app.hapi.companion.di.HubGraph +import app.hapi.companion.di.LocalAppGraph +import app.hapi.companion.di.viewModelFactory +import app.hapi.companion.feature.chat.ChatMedia +import app.hapi.companion.feature.chat.ChatScreen +import app.hapi.companion.feature.chat.ChatViewModel +import app.hapi.companion.feature.chat.composer.DictationController +import app.hapi.companion.feature.chat.composer.HapiDictationApi +import app.hapi.companion.feature.chat.composer.MediaRecorderDictation +import app.hapi.companion.feature.files.ApiFilesGateway +import app.hapi.companion.feature.files.FileViewerScreen +import app.hapi.companion.feature.files.FileViewerStrings +import app.hapi.companion.feature.files.FileViewerViewModel +import app.hapi.companion.feature.files.FilesScreen +import app.hapi.companion.feature.files.FilesStrings +import app.hapi.companion.feature.files.FilesViewModel +import app.hapi.companion.feature.files.ViewerMode +import app.hapi.companion.feature.home.HomeScreen +import app.hapi.companion.feature.newsession.ApiNewSessionGateway +import app.hapi.companion.feature.newsession.NewSessionPrefs +import app.hapi.companion.feature.newsession.NewSessionScreen +import app.hapi.companion.feature.newsession.NewSessionStrings +import app.hapi.companion.feature.newsession.NewSessionViewModel +import app.hapi.companion.feature.pairing.ManualEntryScreen +import app.hapi.companion.feature.pairing.PairingScreen +import app.hapi.companion.feature.pairing.PairingUiState +import app.hapi.companion.feature.pairing.PairingViewModel +import app.hapi.companion.feature.pairing.QrScanScreen +import app.hapi.companion.feature.scratchlist.ContentResolverAttachmentImporter +import app.hapi.companion.feature.scratchlist.ScratchlistMedia +import app.hapi.companion.feature.scratchlist.ScratchlistScreen +import app.hapi.companion.feature.scratchlist.ScratchlistViewModel +import app.hapi.companion.feature.sessions.SessionListViewModel +import app.hapi.companion.feature.settings.SettingsScreen +import app.hapi.companion.feature.settings.SettingsViewModel +import app.hapi.companion.feature.settings.StorageScreen +import app.hapi.companion.feature.settings.StorageViewModel +import app.hapi.companion.feature.settings.UsageScreen +import app.hapi.companion.feature.settings.UsageViewModel +import app.hapi.data.auth.AuthTerminalReason +import java.util.Base64 +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +object Routes { + const val HOME = "home" + + /** Read-only chat (B-M2d2). */ + const val CHAT = "chat/{sessionId}" + + fun chat(sessionId: String) = "chat/$sessionId" + + /** Session files browser (B-M4c): Changes / Browse / Search tabs. */ + const val FILES = "chat/{sessionId}/files" + + fun files(sessionId: String) = "chat/$sessionId/files" + + /** + * File viewer (B-M4c). `path` is base64url (no padding) so slashes and + * specials survive the route pattern — the web twin does the same + * (`encodeBase64` in `files.tsx`). `staged` picks the diff side, `mode` + * (`diff`/`file`) the initial mode, `line` a citation line hint. + */ + const val FILE_VIEWER = "chat/{sessionId}/file?path={path}&staged={staged}&mode={mode}&line={line}" + + fun fileViewer( + sessionId: String, + path: String, + staged: Boolean? = null, + mode: String? = null, + line: Int? = null, + ): String = buildString { + append("chat/").append(sessionId).append("/file?path=").append(encodeFilePath(path)) + staged?.let { append("&staged=").append(it) } + mode?.let { append("&mode=").append(it) } + line?.let { append("&line=").append(it) } + } + + fun encodeFilePath(path: String): String = + Base64.getUrlEncoder().withoutPadding().encodeToString(path.toByteArray(Charsets.UTF_8)) + + fun decodeFilePath(encoded: String): String? = try { + String(Base64.getUrlDecoder().decode(encoded), Charsets.UTF_8) + } catch (_: IllegalArgumentException) { + null + } + + /** Per-session scratchlist workbench (B-M4d), pushed above its chat. */ + const val SCRATCHLIST = "chat/{sessionId}/scratchlist" + + fun scratchlist(sessionId: String) = "chat/$sessionId/scratchlist" + + /** New-session form (B-M3d); optional machine preselect. */ + const val NEW_SESSION = "newSession?machineId={machineId}" + + fun newSession(machineId: String? = null) = + if (machineId == null) "newSession" else "newSession?machineId=$machineId" + + /** Nested pairing graph (landing ⇄ scan ⇄ manual share one ViewModel). */ + const val PAIRING = "pairing" + const val PAIRING_LANDING = "pairing/landing" + const val PAIRING_SCAN = "pairing/scan" + const val PAIRING_MANUAL = "pairing/manual" + + /** Settings scaffold + owner-only dashboards (B-M4e). */ + const val SETTINGS = "settings" + const val SETTINGS_USAGE = "settings/usage" + const val SETTINGS_STORAGE = "settings/storage" +} + +/** + * Root navigation: `pairing` (start when no active hub) ⇄ `home` (session + * list) → `chat/{sessionId}`. Reacts to the graph's cross-cutting flows — + * terminal auth events and active-hub removal route back to pairing (with an + * explanatory banner), pending `hapicompanion://bind` deep links route to the + * pairing confirm card, and a hub switch pops any open chat (its session + * belongs to the previous hub). + */ +@Composable +fun HapiNavigation() { + val graph = LocalAppGraph.current + val ready by graph.ready.collectAsState() + if (!ready) { + // Sub-frame gap while the persisted hub roster loads. + Surface(modifier = Modifier.fillMaxSize()) {} + return + } + + val navController = rememberNavController() + val registryState by graph.hubRegistry.state.collectAsState() + val activeHubGraph by graph.activeHubGraph.collectAsState() + val startDestination = remember { + if (graph.hubRegistry.activeHubUrl == null) Routes.PAIRING else Routes.HOME + } + + // Silent re-auth gave up for good: back to pairing, with the reason. + val terminalContext = LocalContext.current + LaunchedEffect(navController, terminalContext) { + graph.authTerminals.collect { terminal -> + if (terminal.hubUrl == graph.hubRegistry.activeHubUrl) { + graph.pairingNotice.value = terminalContext.getString(terminalNoticeRes(terminal.reason)) + navController.navigateClearingBackStack(Routes.PAIRING) + } + } + } + + // A bind deep link arrived (cold start or onNewIntent): surface the + // pairing screen; the landing destination consumes the link itself. + val pendingBind by graph.pendingBindLink.collectAsState() + LaunchedEffect(pendingBind) { + if (pendingBind != null) { + navController.navigateClearingBackStack(Routes.PAIRING) + } + } + + // A push notification was tapped (B-M4a): open that session's chat over + // home. Multi-hub caveat: the payload names no hub, so the chat opens + // against the ACTIVE hub — for a session living on another paired hub the + // screen shows its not-found state (the workers, by contrast, do resolve + // across hubs; see PushActionRunner). Unpaired app: ignore. + val pendingOpenSession by graph.pendingOpenSessionId.collectAsState() + LaunchedEffect(pendingOpenSession) { + val sessionId = pendingOpenSession ?: return@LaunchedEffect + graph.pendingOpenSessionId.value = null + if (graph.hubRegistry.activeHubUrl != null) { + navController.navigate(Routes.chat(sessionId)) { + // Keep the stack shallow: back always lands on the list. + popUpTo(Routes.HOME) + launchSingleTop = true + } + } + } + + // Last hub signed out (or roster wiped): nothing to show but pairing. + // Any other active-hub change invalidates an open chat (old hub's session). + LaunchedEffect(registryState.activeHubUrl) { + val activeHubUrl = registryState.activeHubUrl + if (activeHubUrl == null) { + val onPairing = navController.currentDestination + ?.hierarchy?.any { it.route == Routes.PAIRING } == true + if (!onPairing) { + navController.navigateClearingBackStack(Routes.PAIRING) + } + } else if ( + navController.currentDestination?.route in + setOf(Routes.CHAT, Routes.FILES, Routes.FILE_VIEWER, Routes.SCRATCHLIST) + ) { + navController.popBackStack(Routes.HOME, inclusive = false) + } + } + + NavHost(navController = navController, startDestination = startDestination) { + composable(Routes.HOME) { + val activeHubUrl = registryState.activeHubUrl ?: return@composable + val hubGraph = activeHubGraph ?: return@composable + val scope = rememberCoroutineScope() + val holder = viewModel( + key = "sessions:${hubGraph.hubUrl}", + factory = viewModelFactory { SessionListViewModelHolder(hubGraph) }, + ) + HomeScreen( + viewModel = holder.viewModel, + activeHubUrl = activeHubUrl, + pairedHubs = registryState.hubs, + onSwitchHub = { hub -> scope.launch { graph.hubRegistry.setActiveHub(hub) } }, + onPairAnotherHub = { navController.navigate(Routes.PAIRING) }, + onSignOut = { scope.launch { graph.signOut(activeHubUrl) } }, + onOpenSession = { sessionId -> navController.navigate(Routes.chat(sessionId)) }, + onNewSession = { navController.navigate(Routes.newSession()) }, + onOpenSettings = { navController.navigate(Routes.SETTINGS) }, + ) + } + + composable(Routes.SETTINGS) { + val hubGraph = activeHubGraph ?: return@composable + val holder = viewModel( + key = "settings:${hubGraph.hubUrl}", + factory = viewModelFactory { SettingsViewModelHolder(graph, hubGraph) }, + ) + SettingsScreen( + viewModel = holder.viewModel, + onOpenUsage = { navController.navigate(Routes.SETTINGS_USAGE) }, + onOpenStorage = { navController.navigate(Routes.SETTINGS_STORAGE) }, + onBack = { navController.popBackStack() }, + ) + } + + composable(Routes.SETTINGS_USAGE) { + val hubGraph = activeHubGraph ?: return@composable + val holder = viewModel( + key = "settingsUsage:${hubGraph.hubUrl}", + factory = viewModelFactory { UsageViewModelHolder(hubGraph) }, + ) + UsageScreen( + viewModel = holder.viewModel, + onBack = { navController.popBackStack() }, + ) + } + + composable(Routes.SETTINGS_STORAGE) { + val hubGraph = activeHubGraph ?: return@composable + val holder = viewModel( + key = "settingsStorage:${hubGraph.hubUrl}", + factory = viewModelFactory { StorageViewModelHolder(hubGraph) }, + ) + StorageScreen( + viewModel = holder.viewModel, + onBack = { navController.popBackStack() }, + ) + } + + composable( + route = Routes.CHAT, + arguments = listOf(navArgument("sessionId") { type = NavType.StringType }), + ) { entry -> + val sessionId = entry.arguments?.getString("sessionId") ?: return@composable + val hubGraph = activeHubGraph ?: return@composable + val appContext = LocalContext.current.applicationContext + + // Feed the FCM suppress-when-open rule: while this chat is on + // screen, its session's pushes stay silent (SSE already shows + // them). The guard on dispose handles enter-before-exit ordering + // during supersede navigation (chat B composes before A leaves). + DisposableEffect(sessionId) { + graph.openChatSessionId.value = sessionId + onDispose { + if (graph.openChatSessionId.value == sessionId) { + graph.openChatSessionId.value = null + } + } + } + val holder = viewModel( + key = "chat:${hubGraph.hubUrl}:$sessionId", + factory = viewModelFactory { ChatViewModelHolder(hubGraph, sessionId, appContext) }, + ) + ChatScreen( + viewModel = holder.viewModel, + media = remember(hubGraph, sessionId) { + ChatMedia(hubGraph.imageLoader) { imageId -> + hubGraph.generatedImageUrl(sessionId, imageId) + } + }, + onBack = { navController.popBackStack() }, + onNavigateToSession = { supersededId -> + // Resume/reopen handed the conversation to a different id: + // replace this chat entry with the superseding session. + navController.navigate(Routes.chat(supersededId)) { + popUpTo(Routes.CHAT) { inclusive = true } + } + }, + dictation = holder.dictation, + onOpenFiles = { navController.navigate(Routes.files(sessionId)) }, + onOpenFile = { path, line -> + // Chat citations open full mode; the cited line renders as + // a hint chip (no per-line highlight — B-M4c trade-off). + navController.navigate(Routes.fileViewer(sessionId, path, mode = "file", line = line)) + }, + onOpenScratchlist = { navController.navigate(Routes.scratchlist(sessionId)) }, + ) + } + + composable( + route = Routes.FILES, + arguments = listOf(navArgument("sessionId") { type = NavType.StringType }), + ) { entry -> + val sessionId = entry.arguments?.getString("sessionId") ?: return@composable + val hubGraph = activeHubGraph ?: return@composable + val filesContext = LocalContext.current + val holder = viewModel( + key = "files:${hubGraph.hubUrl}:$sessionId", + factory = viewModelFactory { + FilesViewModelHolder(hubGraph, sessionId, filesStrings(filesContext)) + }, + ) + FilesScreen( + viewModel = holder.viewModel, + onBack = { navController.popBackStack() }, + onOpenFile = { path, staged -> + navController.navigate(Routes.fileViewer(sessionId, path, staged = staged)) + }, + ) + } + + composable( + route = Routes.FILE_VIEWER, + arguments = listOf( + navArgument("sessionId") { type = NavType.StringType }, + navArgument("path") { + type = NavType.StringType + nullable = true + defaultValue = null + }, + navArgument("staged") { + type = NavType.StringType + nullable = true + defaultValue = null + }, + navArgument("mode") { + type = NavType.StringType + nullable = true + defaultValue = null + }, + navArgument("line") { + type = NavType.StringType + nullable = true + defaultValue = null + }, + ), + ) { entry -> + val sessionId = entry.arguments?.getString("sessionId") ?: return@composable + val hubGraph = activeHubGraph ?: return@composable + val encodedPath = entry.arguments?.getString("path") ?: return@composable + val path = Routes.decodeFilePath(encodedPath) ?: return@composable + val staged = entry.arguments?.getString("staged")?.toBooleanStrictOrNull() + val mode = when (entry.arguments?.getString("mode")) { + "diff" -> ViewerMode.DIFF + "file" -> ViewerMode.FILE + else -> null + } + val line = entry.arguments?.getString("line")?.toIntOrNull() + val viewerContext = LocalContext.current + val holder = viewModel( + key = "file:${hubGraph.hubUrl}:$sessionId:$encodedPath:$staged:$mode", + factory = viewModelFactory { + FileViewerViewModelHolder( + hubGraph, sessionId, path, staged, mode, line, + fileViewerStrings(viewerContext), + ) + }, + ) + FileViewerScreen( + viewModel = holder.viewModel, + onBack = { navController.popBackStack() }, + ) + } + + composable( + route = Routes.SCRATCHLIST, + arguments = listOf(navArgument("sessionId") { type = NavType.StringType }), + ) { entry -> + val sessionId = entry.arguments?.getString("sessionId") ?: return@composable + val hubGraph = activeHubGraph ?: return@composable + val appContext = LocalContext.current.applicationContext + val holder = viewModel( + key = "scratchlist:${hubGraph.hubUrl}:$sessionId", + factory = viewModelFactory { ScratchlistViewModelHolder(hubGraph, sessionId, appContext) }, + ) + // "Send to composer" reuses the chat ViewModel of the entry below + // this route (same holder key + the chat entry as owner), so the + // inserted text lands in the live composer state, not a stale + // draft. Guarded: without a chat below, the affordance hides. + val chatEntry = remember(entry) { + runCatching { navController.getBackStackEntry(Routes.CHAT) }.getOrNull() + } + val chatHolder = chatEntry?.let { owner -> + viewModel( + viewModelStoreOwner = owner, + key = "chat:${hubGraph.hubUrl}:$sessionId", + factory = viewModelFactory { ChatViewModelHolder(hubGraph, sessionId, appContext) }, + ) + } + ScratchlistScreen( + viewModel = holder.viewModel, + media = remember(hubGraph, sessionId) { + ScratchlistMedia(hubGraph.imageLoader) { attachmentId -> + hubGraph.scratchlistAttachmentUrl(sessionId, attachmentId) + } + }, + onBack = { navController.popBackStack() }, + onSendToComposer = chatHolder?.let { chat -> + { scratchEntry -> + chat.viewModel.insertComposerText(scratchEntry.text) + navController.popBackStack() + } + }, + ) + } + + composable( + route = Routes.NEW_SESSION, + arguments = listOf( + navArgument("machineId") { + type = NavType.StringType + nullable = true + defaultValue = null + }, + ), + ) { entry -> + val hubGraph = activeHubGraph ?: return@composable + val machineId = entry.arguments?.getString("machineId") + val formContext = LocalContext.current + val holder = viewModel( + key = "newSession:${hubGraph.hubUrl}", + factory = viewModelFactory { + NewSessionViewModelHolder( + hubGraph, graph.newSessionPrefs, machineId, + newSessionStrings(formContext), + ) + }, + ) + NewSessionScreen( + viewModel = holder.viewModel, + onBack = { navController.popBackStack() }, + onCreated = { sessionId -> + // Navigate-replace: the form pops so back from the new + // chat lands on the session list, not a stale form. + navController.navigate(Routes.chat(sessionId)) { + popUpTo(Routes.HOME) + launchSingleTop = true + } + }, + ) + } + + navigation(startDestination = Routes.PAIRING_LANDING, route = Routes.PAIRING) { + composable(Routes.PAIRING_LANDING) { entry -> + val viewModel = pairingViewModel(navController, entry) + val state by viewModel.state.collectAsState() + val prefill by viewModel.prefill.collectAsState() + val notice by graph.pairingNotice.collectAsState() + + // Consume the deep link into the shared pairing ViewModel. + LaunchedEffect(pendingBind) { + pendingBind?.let { link -> + viewModel.prefillFromLink(link) + graph.pendingBindLink.value = null + } + } + NavigateHomeOnSuccess(navController, state) + + PairingScreen( + state = state, + prefill = prefill, + notice = notice, + onDismissNotice = { graph.pairingNotice.value = null }, + onScanQr = { navController.navigate(Routes.PAIRING_SCAN) }, + onManualEntry = { navController.navigate(Routes.PAIRING_MANUAL) }, + onPairPrefill = viewModel::pairFromPrefill, + onSwitchToPrefilledHub = viewModel::switchToPrefilledHub, + onDismissPrefill = viewModel::dismissPrefill, + onDismissError = viewModel::dismissError, + ) + } + + composable(Routes.PAIRING_SCAN) { entry -> + val viewModel = pairingViewModel(navController, entry) + val state by viewModel.state.collectAsState() + NavigateHomeOnSuccess(navController, state) + + QrScanScreen( + state = state, + onPairLink = { link -> viewModel.pair(link.hubUrl, link.accessToken) }, + onManualEntry = { navController.navigate(Routes.PAIRING_MANUAL) }, + onBack = { navController.popBackStack() }, + onDismissError = viewModel::dismissError, + ) + } + + composable(Routes.PAIRING_MANUAL) { entry -> + val viewModel = pairingViewModel(navController, entry) + val state by viewModel.state.collectAsState() + NavigateHomeOnSuccess(navController, state) + + ManualEntryScreen( + state = state, + onPair = viewModel::pair, + onBack = { navController.popBackStack() }, + onDismissError = viewModel::dismissError, + ) + } + } + } +} + +// ------------------------------------------------------ ViewModel holders -- + +/** + * Androidx-lifecycle shell around the plain [SessionListViewModel]: survives + * config changes with the nav entry, owns the combine scope, and tears the + * global SSE pipe down when the entry clears. Keyed per hub so a hub switch + * builds a fresh one against the new [HubGraph]. + */ +private class SessionListViewModelHolder(hubGraph: HubGraph) : ViewModel() { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + val viewModel = SessionListViewModel( + sessionStore = hubGraph.sessionStore, + machineStore = hubGraph.machineStore, + lastSeenStore = hubGraph.lastSeenStore, + scope = scope, + hubKey = hubGraph.hubUrl, + ) + + override fun onCleared() { + viewModel.stop() + scope.cancel() + } +} + +/** Same shell for the per-session [ChatViewModel] (+ its dictation controller). */ +private class ChatViewModelHolder( + hubGraph: HubGraph, + sessionId: String, + appContext: Context, +) : ViewModel() { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + val viewModel = ChatViewModel( + sessionId = sessionId, + api = hubGraph.session.api, + sessionStore = hubGraph.sessionStore, + machineStore = hubGraph.machineStore, + lastSeenStore = hubGraph.lastSeenStore, + messageWindows = hubGraph.messageWindows, + sseEngine = hubGraph.sseEngine, + syncTargets = hubGraph.syncTargets, + scope = scope, + drafts = hubGraph.chatDrafts, + scratchlist = hubGraph.scratchlistStore, + ) + + /** + * Holder-scoped so a recording survives rotation; [onCleared] discards + * any take still open when the screen goes away for good. + */ + val dictation = DictationController( + api = HapiDictationApi(hubGraph.session.api), + recorder = MediaRecorderDictation(appContext), + scope = scope, + ) + + override fun onCleared() { + dictation.cancel() + // Leaving the chat for good (not rotation): un-sent uploaded + // attachments are discarded after a best-effort hub delete (B-M3f; + // attachments are not part of drafts v1). + viewModel.discardAttachments() + viewModel.stop() + scope.cancel() + } +} + +/** Shell for the files browser (B-M4c); keyed per hub+session. */ +private class FilesViewModelHolder( + hubGraph: HubGraph, + sessionId: String, + strings: FilesStrings, +) : ViewModel() { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + val viewModel = FilesViewModel( + sessionId = sessionId, + gateway = ApiFilesGateway(hubGraph.session.api), + scope = scope, + strings = strings, + ) + + override fun onCleared() { + scope.cancel() + } +} + +/** Shell for the single-file viewer (B-M4c). */ +private class FileViewerViewModelHolder( + hubGraph: HubGraph, + sessionId: String, + path: String, + staged: Boolean?, + mode: ViewerMode?, + line: Int?, + strings: FileViewerStrings, +) : ViewModel() { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + val viewModel = FileViewerViewModel( + sessionId = sessionId, + path = path, + initialStaged = staged, + initialMode = mode, + focusLine = line, + gateway = ApiFilesGateway(hubGraph.session.api), + scope = scope, + strings = strings, + ) + + override fun onCleared() { + scope.cancel() + } +} + +/** + * Shells for the settings graph (B-M4e). Settings/usage/storage are hub-scoped + * (keys include the hub origin): a hub switch swaps in fresh state. + */ +private class SettingsViewModelHolder(graph: AppGraph, hubGraph: HubGraph) : ViewModel() { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + val viewModel = SettingsViewModel( + themePrefs = graph.themePrefs, + languagePrefs = graph.languagePrefs, + hubUrl = hubGraph.hubUrl, + currentJwt = { + // Store reads may block (EncryptedSharedPreferences) and the + // fallback exchange is network I/O. + withContext(Dispatchers.IO) { + hubGraph.session.authenticator.currentJwt() ?: hubGraph.session.ensureFreshToken() + } + }, + fetchHealth = { hubGraph.session.api.health() }, + scope = scope, + ) + + override fun onCleared() { + scope.cancel() + } +} + +private class UsageViewModelHolder(hubGraph: HubGraph) : ViewModel() { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + val viewModel = UsageViewModel( + gateway = hubGraph.session.api::getUsageSummary, + scope = scope, + ) + + override fun onCleared() { + scope.cancel() + } +} + +private class StorageViewModelHolder(hubGraph: HubGraph) : ViewModel() { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + val viewModel = StorageViewModel( + gateway = hubGraph.session.api::getSqliteStorageUsage, + scope = scope, + ) + + override fun onCleared() { + scope.cancel() + } +} + +/** Shell for the per-session scratchlist workbench (B-M4d). */ +private class ScratchlistViewModelHolder( + hubGraph: HubGraph, + sessionId: String, + appContext: Context, +) : ViewModel() { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + val viewModel = ScratchlistViewModel( + sessionId = sessionId, + store = hubGraph.scratchlistStore, + scope = scope, + importer = ContentResolverAttachmentImporter(appContext), + ) + + override fun onCleared() { + viewModel.stop() + scope.cancel() + } +} + +/** Shell for the create form (B-M3d); draft persistence survives via prefs. */ +private class NewSessionViewModelHolder( + hubGraph: HubGraph, + prefs: NewSessionPrefs, + initialMachineId: String?, + strings: NewSessionStrings, +) : ViewModel() { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + val viewModel = NewSessionViewModel( + gateway = ApiNewSessionGateway(hubGraph.session.api), + machineStore = hubGraph.machineStore, + prefs = prefs, + scope = scope, + initialMachineId = initialMachineId, + strings = strings, + ) + + override fun onCleared() { + scope.cancel() + } +} + +/** + * The one [PairingViewModel], scoped to the pairing nav-graph entry so the + * landing/scan/manual destinations share pairing state and it is cleared as + * soon as the graph pops. + */ +@Composable +private fun pairingViewModel( + navController: NavHostController, + entry: NavBackStackEntry, +): PairingViewModel { + val graph = LocalAppGraph.current + val parentEntry = remember(entry) { navController.getBackStackEntry(Routes.PAIRING) } + return viewModel( + viewModelStoreOwner = parentEntry, + factory = viewModelFactory { + PairingViewModel( + clientFactory = graph.pairingClientFactory, + credentialStore = graph.credentialStore, + registry = graph.hubRegistry, + ) + }, + ) +} + +/** Pairing finished: clear any stale notice and land on home, stack reset. */ +@Composable +private fun NavigateHomeOnSuccess(navController: NavHostController, state: PairingUiState) { + val graph = LocalAppGraph.current + LaunchedEffect(state) { + if (state is PairingUiState.Success) { + graph.pairingNotice.value = null + navController.navigateClearingBackStack(Routes.HOME) + } + } +} + +private fun NavHostController.navigateClearingBackStack(route: String) { + navigate(route) { + popUpTo(0) { inclusive = true } + launchSingleTop = true + } +} + +/** Why the app fell back to pairing (resolved with a Context at the emit site). */ +private fun terminalNoticeRes(reason: AuthTerminalReason): Int = when (reason) { + AuthTerminalReason.ACCESS_TOKEN_REJECTED -> R.string.pairing_notice_token_revoked + AuthTerminalReason.RETRY_EXHAUSTED -> R.string.pairing_notice_retry_exhausted + AuthTerminalReason.MISSING_CREDENTIALS -> R.string.pairing_notice_missing_credentials +} + +/** Resource-resolved [FilesStrings] (B-M5a Strings seam). */ +private fun filesStrings(context: Context) = FilesStrings( + gitStatusUnavailable = context.getString(R.string.files_error_git_status), + unstagedDiffUnavailable = context.getString(R.string.files_error_unstaged_diff), + stagedDiffUnavailable = context.getString(R.string.files_error_staged_diff), + unknownError = context.getString(R.string.files_error_unknown), + listDirectoryFailed = context.getString(R.string.files_error_list), + searchFailed = context.getString(R.string.files_error_search), +) + +/** Resource-resolved [FileViewerStrings] (B-M5a Strings seam). */ +private fun fileViewerStrings(context: Context) = FileViewerStrings( + loadDiffFailed = context.getString(R.string.files_error_load_diff), + readFileFailed = context.getString(R.string.files_error_read_file), +) + +/** Resource-resolved [NewSessionStrings] (B-M5a Strings seam). */ +private fun newSessionStrings(context: Context) = NewSessionStrings( + worktreeMissing = context.getString(R.string.new_session_error_worktree_missing), + directoryMissing = context.getString(R.string.new_session_error_directory_missing), + directoryMissingConfirm = context.getString(R.string.new_session_error_directory_missing_confirm), + createFailed = context.getString(R.string.new_session_error_create), + codexModelsFailed = context.getString(R.string.new_session_error_codex_models), + modelsFailedDetail = context.getString(R.string.new_session_error_models_detail), + worktreeNameInvalid = context.getString(R.string.new_session_error_worktree_name), +) diff --git a/android/app/src/main/kotlin/app/hapi/companion/di/AppGraph.kt b/android/app/src/main/kotlin/app/hapi/companion/di/AppGraph.kt new file mode 100644 index 0000000000..5fed1cf8fd --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/di/AppGraph.kt @@ -0,0 +1,284 @@ +package app.hapi.companion.di + +import android.content.Context +import androidx.datastore.preferences.preferencesDataStore +import app.hapi.companion.fcm.RegisterDeviceWorker +import app.hapi.companion.feature.newsession.DataStoreNewSessionPrefs +import app.hapi.companion.feature.newsession.NewSessionPrefs +import app.hapi.companion.feature.pairing.PairingClient +import app.hapi.companion.feature.pairing.PairingClientFactory +import app.hapi.companion.feature.settings.AppLanguage +import app.hapi.companion.feature.settings.LanguagePrefs +import app.hapi.companion.feature.settings.ThemePrefs +import app.hapi.companion.push.DataStorePushDeviceIds +import app.hapi.companion.push.PushBinding +import app.hapi.data.api.HapiApi +import app.hapi.data.auth.AuthEvents +import app.hapi.data.auth.AuthTerminalReason +import app.hapi.data.auth.CredentialStore +import app.hapi.data.auth.EncryptedPrefsCredentialStore +import app.hapi.data.auth.HubRegistry +import app.hapi.data.auth.HubRegistryStorage +import app.hapi.data.push.ApiPushDeviceGateway +import app.hapi.data.push.DeviceRegistrar +import app.hapi.data.push.PushActionRunner +import app.hapi.data.push.PushHubAccess +import app.hapi.protocol.pairing.BindLink +import app.hapi.protocol.wire.AuthResponse +import app.hapi.protocol.wire.HapiJson +import app.hapi.protocol.wire.HubHealthResponse +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.serialization.json.Json +import okhttp3.OkHttpClient + +/** One terminal auth failure, re-emitted off OkHttp threads as a flow. */ +data class AuthTerminal( + val hubUrl: String, + val reason: AuthTerminalReason, +) + +/** Process-wide Preferences DataStore (hub roster + future app settings). */ +private val Context.hapiDataStore by preferencesDataStore(name = "hapi_prefs") + +/** + * Process-singleton graph, hand-rolled (no Hilt by design — see plan track B). + * Constructed once in [app.hapi.companion.HapiApp]; Compose reads it via + * [LocalAppGraph]; per-active-hub types live in [HubGraph], swapped by this + * class whenever `HubRegistry.state`'s active hub changes. + * + * Call [start] right after construction: it loads the persisted roster + * ([ready] flips true) and then keeps [activeHubGraph] in sync with the + * registry. + */ +class AppGraph(context: Context) { + + private val appContext = context.applicationContext + + /** App-lifetime scope; nothing here is ever torn down before the process. */ + val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + /** The protocol-configured Json (lenient, ignores unknown keys). */ + val json: Json = HapiJson + + val hubRegistryStorage: HubRegistryStorage = + DataStoreHubRegistryStorage(appContext.hapiDataStore) + + val credentialStore: CredentialStore = EncryptedPrefsCredentialStore(appContext) + + val hubRegistry: HubRegistry = HubRegistry(hubRegistryStorage) + + /** + * Create-form persistence (last machine / recent paths / draft), app-wide + * like the web's localStorage twin — machine ids are globally unique, so + * hub scoping is unnecessary. + */ + val newSessionPrefs: NewSessionPrefs = DataStoreNewSessionPrefs(appContext.hapiDataStore) + + /** Appearance choice (B-M4e); MainActivity reads it at setContent. */ + val themePrefs: ThemePrefs = ThemePrefs(appContext.hapiDataStore) + + /** Language choice (B-M5a: applied via per-app locales; Settings writes it). */ + val languagePrefs: LanguagePrefs = LanguagePrefs(appContext.hapiDataStore) + + /** + * Eagerly-cached language for non-composable surfaces (B-M5a): FCM + * notifications and WorkManager updates resolve strings from the + * application context, which appcompat's per-app locales do not retarget + * on API < 33 — they wrap it via `localizedForAppLanguage` instead. May + * briefly read [AppLanguage.SYSTEM] on a cold process before the first + * DataStore emission (a system-locale notification once — benign). + */ + val appLanguage: StateFlow = languagePrefs.language + .stateIn(scope, SharingStarted.Eagerly, AppLanguage.SYSTEM) + + private val mutableAuthTerminals = MutableSharedFlow(extraBufferCapacity = 16) + + /** + * Terminal auth failures for any hub (re-pair required). Fired by OkHttp + * worker threads via [authEvents]; navigation collects and routes to the + * pairing screen with an explanatory banner. + */ + val authTerminals: SharedFlow = mutableAuthTerminals.asSharedFlow() + + /** The [AuthEvents] sink every [HubSession][app.hapi.data.HubSession] gets. */ + val authEvents: AuthEvents = AuthEvents { hubUrl, reason -> + mutableAuthTerminals.tryEmit(AuthTerminal(hubUrl, reason)) + } + + /** + * The most recent unconsumed `hapicompanion://bind` deep link. + * MainActivity posts (cold start + onNewIntent); the pairing screen + * consumes and clears. + */ + val pendingBindLink = MutableStateFlow(null) + + /** + * Session id from a tapped push notification, waiting for navigation + * (the internal intent route — no public URI). MainActivity posts; + * `HapiNavigation` consumes, clears, and opens the chat. + */ + val pendingOpenSessionId = MutableStateFlow(null) + + /** + * The session id of the currently composed chat screen, or null. Feeds + * the FCM suppress-when-open rule (`shouldSuppressPush`): while that + * exact session is on screen in the foreground, the in-app SSE stream + * already shows the event, so no OS notification is posted for it. + */ + val openChatSessionId = MutableStateFlow(null) + + /** One-line banner for the pairing screen ("signed out because …"). */ + val pairingNotice = MutableStateFlow(null) + + /** + * Bare client for the two pre-pairing endpoints (`GET /health`, + * `POST /api/auth`) — no interceptors: there are no credentials yet. + */ + private val pairingHttpClient: OkHttpClient = OkHttpClient.Builder() + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(20, TimeUnit.SECONDS) + .build() + + /** Builds the pairing probe for a candidate hub URL (normalized upstream). */ + val pairingClientFactory: PairingClientFactory = PairingClientFactory { hubUrl -> + val api = HapiApi(hubUrl = hubUrl, client = pairingHttpClient) + object : PairingClient { + override suspend fun health(): HubHealthResponse = api.health() + override suspend fun authenticate(accessToken: String): AuthResponse = + api.authenticate(accessToken) + } + } + + // ------------------------------------------------------------ push (B-M4a) -- + + /** Authed per-hub API access for background push work (no HubGraph needed). */ + val pushHubAccess: PushHubAccess = PushHubAccess(hubRegistry, credentialStore, authEvents) + + /** Executes notification actions with active-hub-first resolution. */ + val pushActionRunner: PushActionRunner = PushActionRunner(pushHubAccess) + + /** + * FCM device registration fan-out: every paired hub gets this install's + * token (`POST /api/devices/register`), keyed by a DataStore-persisted + * UUID. All entry points no-op when Firebase isn't configured + * ([PushBinding.currentToken] returns null). + */ + val deviceRegistrar: DeviceRegistrar = DeviceRegistrar( + registry = hubRegistry, + gateway = ApiPushDeviceGateway(pushHubAccess), + tokenSource = { PushBinding.currentToken(appContext) }, + deviceIds = DataStorePushDeviceIds(appContext.hapiDataStore), + retryScheduler = { hubUrl -> RegisterDeviceWorker.enqueueRetry(appContext, hubUrl) }, + scope = scope, + ) + + /** `onNewToken` hook: waits for the roster load, then re-registers everywhere. */ + fun onPushTokenRotated(token: String) { + scope.launch { + awaitReady() + deviceRegistrar.onNewToken(token) + } + } + + /** Suspends until the persisted hub roster is loaded (workers, FCM paths). */ + suspend fun awaitReady() { + ready.first { it } + } + + // -------------------------------------------------------------------------- + + private val mutableActiveHubGraph = MutableStateFlow(null) + + /** Per-active-hub graph; null while unpaired. Recreated on hub switch. */ + val activeHubGraph: StateFlow = mutableActiveHubGraph.asStateFlow() + + private val mutableReady = MutableStateFlow(false) + + /** False until the persisted hub roster is loaded (gate the first frame). */ + val ready: StateFlow = mutableReady.asStateFlow() + + /** Idempotent-enough for the single Application.onCreate call site. */ + fun start() { + scope.launch { + hubRegistry.load() + mutableReady.value = true + // Roster is loaded: the registrar's first emission re-registers + // every persisted hub (cheap upsert), then fresh pairings as added. + deviceRegistrar.start() + hubRegistry.state + .map { it.activeHubUrl } + .distinctUntilChanged() + .collect { activeHubUrl -> swapActiveHub(activeHubUrl) } + } + } + + /** + * Removes [hubUrl]'s pairing: FCM registration deleted best-effort (must + * happen first, while this hub's JWT still works — afterwards nothing + * could ever authenticate the DELETE), then credentials wiped and the + * roster entry dropped (the registry auto-activates the next hub, or + * none). The active [HubGraph] swap follows via the registry observer. + */ + suspend fun signOut(hubUrl: String) { + withTimeoutOrNull(UNREGISTER_TIMEOUT_MS) { deviceRegistrar.unregisterHub(hubUrl) } + withContext(Dispatchers.IO) { credentialStore.delete(hubUrl) } + hubRegistry.removeHub(hubUrl) + } + + /** Sequential by construction: only the [start] collector calls this. */ + private fun swapActiveHub(activeHubUrl: String?) { + mutableActiveHubGraph.value?.close() + mutableActiveHubGraph.value = activeHubUrl?.let { hubUrl -> + HubGraph( + hubUrl = hubUrl, + credentialStore = credentialStore, + authEvents = authEvents, + context = appContext, + ).also { graph -> + // A hub activated while backgrounded must not burn retries. + if (!isForeground) graph.setLifecycleForeground(false) + } + } + } + + @Volatile private var isForeground = true + + /** Current process foreground state (FCM suppress-when-open check). */ + val foreground: Boolean get() = isForeground + + /** + * Process lifecycle input (`ProcessLifecycleOwner` via `HapiApp`): + * forwarded to the active hub's SSE engine (retry deferral / stale-socket + * rebuild) and visibility reporter (`POST /api/visibility`). + */ + fun setForeground(foreground: Boolean) { + isForeground = foreground + mutableActiveHubGraph.value?.setLifecycleForeground(foreground) + } + + private companion object { + /** + * Sign-out unregister is best-effort: bounded so a dead hub cannot + * hang the sign-out UX. A leaked registration self-heals hub-side + * (FCM reports the token dead after uninstall / token rotation). + */ + const val UNREGISTER_TIMEOUT_MS = 5_000L + } +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/di/CompositionLocals.kt b/android/app/src/main/kotlin/app/hapi/companion/di/CompositionLocals.kt new file mode 100644 index 0000000000..9d43b3fe8a --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/di/CompositionLocals.kt @@ -0,0 +1,26 @@ +package app.hapi.companion.di + +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider + +/** + * The process singleton graph, provided at the root of the composition by + * MainActivity (from [app.hapi.companion.HapiApp]). `staticCompositionLocalOf` + * because the instance never changes for the life of the process. + */ +val LocalAppGraph = staticCompositionLocalOf { + error("LocalAppGraph is not provided — wrap the composition in MainActivity") +} + +/** + * Minimal factory for hand-wired ViewModels: + * `viewModel(factory = viewModelFactory { PairingViewModel(graph.…) })`. + * The lambda runs once per store scope; [modelClass] is trusted to match + * because each call site constructs exactly the type it requests. + */ +fun viewModelFactory(create: () -> VM): ViewModelProvider.Factory = + object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = create() as T + } diff --git a/android/app/src/main/kotlin/app/hapi/companion/di/DataStoreChatDrafts.kt b/android/app/src/main/kotlin/app/hapi/companion/di/DataStoreChatDrafts.kt new file mode 100644 index 0000000000..222d432453 --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/di/DataStoreChatDrafts.kt @@ -0,0 +1,52 @@ +package app.hapi.companion.di + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import app.hapi.companion.feature.chat.composer.ChatDrafts +import kotlinx.coroutines.flow.first + +/** + * Process-wide drafts DataStore. Separate from `hapi_prefs` so bursty + * draft writes never contend with the hub roster file. + */ +internal val Context.chatDraftsDataStore: DataStore by preferencesDataStore(name = "chat_drafts") + +/** + * [ChatDrafts] over a Preferences DataStore, keys scoped `draft::` + * so multiple paired hubs never collide on a session id. + */ +class DataStoreChatDrafts( + private val dataStore: DataStore, + private val hubKey: String, +) : ChatDrafts { + + private fun key(sessionId: String): Preferences.Key = + stringPreferencesKey("draft:$hubKey:$sessionId") + + override suspend fun load(sessionId: String): String? = + dataStore.data.first()[key(sessionId)]?.takeIf { it.isNotEmpty() } + + override suspend fun save(sessionId: String, text: String) { + dataStore.edit { prefs -> + if (text.isBlank()) prefs.remove(key(sessionId)) else prefs[key(sessionId)] = text + } + } + + override suspend fun clear(sessionId: String) { + dataStore.edit { prefs -> prefs.remove(key(sessionId)) } + } + + override suspend fun move(fromSessionId: String, toSessionId: String) { + if (fromSessionId == toSessionId) return + dataStore.edit { prefs -> + val draft = prefs[key(fromSessionId)] ?: return@edit + prefs.remove(key(fromSessionId)) + // Never clobber a draft already typed in the target session. + if (prefs[key(toSessionId)].isNullOrEmpty()) prefs[key(toSessionId)] = draft + } + } +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/di/DataStoreHubRegistryStorage.kt b/android/app/src/main/kotlin/app/hapi/companion/di/DataStoreHubRegistryStorage.kt new file mode 100644 index 0000000000..53a46a24f3 --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/di/DataStoreHubRegistryStorage.kt @@ -0,0 +1,39 @@ +package app.hapi.companion.di + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.stringPreferencesKey +import app.hapi.data.auth.HubRegistryStorage +import java.io.IOException +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first + +/** + * [HubRegistryStorage] backed by the app's Preferences DataStore: the + * registry's serialized snapshot lives under one string key. The blob is + * opaque here — `HubRegistry` owns its schema. + * + * A corrupt/unreadable preferences file degrades to "no snapshot" (empty + * roster → pairing screen) instead of crashing; credentials are unaffected + * (they live in the [app.hapi.data.auth.EncryptedPrefsCredentialStore]). + */ +class DataStoreHubRegistryStorage( + private val dataStore: DataStore, +) : HubRegistryStorage { + + override suspend fun read(): String? = + dataStore.data + .catch { error -> if (error is IOException) emit(emptyPreferences()) else throw error } + .first()[KEY] + + override suspend fun write(value: String) { + dataStore.edit { preferences -> preferences[KEY] = value } + } + + companion object { + /** Single-key layout: the registry snapshot JSON. */ + val KEY: Preferences.Key = stringPreferencesKey("hub_registry") + } +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/di/HapiWorkerFactory.kt b/android/app/src/main/kotlin/app/hapi/companion/di/HapiWorkerFactory.kt new file mode 100644 index 0000000000..e9f7fbb875 --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/di/HapiWorkerFactory.kt @@ -0,0 +1,38 @@ +package app.hapi.companion.di + +import android.content.Context +import androidx.work.ListenableWorker +import androidx.work.WorkerFactory +import androidx.work.WorkerParameters +import app.hapi.companion.fcm.PermissionActionWorker +import app.hapi.companion.fcm.RegisterDeviceWorker +import app.hapi.companion.fcm.SendMessageWorker + +/** + * Hand-rolled worker construction (no Hilt, per the plan): the push workers + * need per-hub authed clients, which they reach through [AppGraph]'s push + * surface (`PushActionRunner` / `DeviceRegistrar` — both build `HubSession`s + * on demand from stored credentials, since no `HubGraph` may exist while a + * background worker runs). + * + * Wired via `HapiApp implements Configuration.Provider` together with the + * manifest's on-demand WorkManager initialization, so [AppGraph] exists + * before the first worker is created even when WorkManager cold-starts the + * process. + */ +class HapiWorkerFactory(private val appGraph: () -> AppGraph) : WorkerFactory() { + + override fun createWorker( + appContext: Context, + workerClassName: String, + workerParameters: WorkerParameters, + ): ListenableWorker? = when (workerClassName) { + PermissionActionWorker::class.java.name -> + PermissionActionWorker(appContext, workerParameters, appGraph()) + SendMessageWorker::class.java.name -> + SendMessageWorker(appContext, workerParameters, appGraph()) + RegisterDeviceWorker::class.java.name -> + RegisterDeviceWorker(appContext, workerParameters, appGraph()) + else -> null // unknown class: let WorkManager's default factory try + } +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/di/HubGraph.kt b/android/app/src/main/kotlin/app/hapi/companion/di/HubGraph.kt new file mode 100644 index 0000000000..53a6e839d8 --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/di/HubGraph.kt @@ -0,0 +1,199 @@ +package app.hapi.companion.di + +import android.content.Context +import app.hapi.companion.feature.chat.composer.ChatDrafts +import app.hapi.data.HubSession +import app.hapi.data.auth.AuthEvents +import app.hapi.data.auth.CredentialStore +import app.hapi.data.sse.GlobalSsePipe +import app.hapi.data.sse.OkHttpSseTransport +import app.hapi.data.sse.SseEngine +import app.hapi.data.sse.SseTokenProvider +import app.hapi.data.sse.SyncEventRouter +import app.hapi.data.sse.SyncTargets +import app.hapi.data.sse.VisibilityReporter +import app.hapi.data.store.LastSeenStore +import app.hapi.data.store.MachineStore +import app.hapi.data.store.MessageWindowStores +import app.hapi.data.store.ScratchlistStore +import app.hapi.data.store.SessionStore +import app.hapi.data.store.StoreSyncTargets +import app.hapi.data.store.WindowSnapshots +import app.hapi.protocol.wire.SyncEvent +import coil.ImageLoader +import java.io.Closeable +import java.io.File +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.withContext + +/** + * Everything scoped to the **active** hub. [AppGraph] creates one per + * active-hub change (observing `HubRegistry.state`) and [close]s the previous + * one — nothing here survives a hub switch. + * + * Wiring: [HubSession] (REST + silent re-auth, from `:core:data`) → its + * `ensureFreshToken` adapts to the [SseEngine]'s token provider (SSE + * authenticates only at connect time) → [SyncEventRouter] fans engine events + * out to [StoreSyncTargets], which feeds the per-hub stores below. Screens + * own the actual SSE subscriptions (session list = global pipe, open chat = + * its session pipe), all against this one engine. + */ +class HubGraph( + hubUrl: String, + credentialStore: CredentialStore, + authEvents: AuthEvents, + /** Application context: Coil loader + cache/files roots derive from it. */ + context: Context, +) : Closeable { + + /** Child of nothing on purpose: cancelled explicitly in [close]. */ + val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + val session: HubSession = HubSession( + hubUrl = hubUrl, + credentialStore = credentialStore, + authEvents = authEvents, + imageCacheDir = File(File(context.cacheDir, "hub-images"), dirNameFor(hubUrl)), + ) + + /** Normalized origin (via [HubSession]'s own normalization). */ + val hubUrl: String get() = session.hubUrl + + val sseEngine: SseEngine = SseEngine( + baseUrl = session.hubUrl, + transport = OkHttpSseTransport(), + tokenProvider = SessionTokenProvider(session, credentialStore), + scope = scope, + ) + + /** Per-hub snapshot root (filesDir — survives cache pressure). */ + private val snapshotDir: File = File(File(context.filesDir, "hubs"), dirNameFor(session.hubUrl)) + + val sessionStore: SessionStore = SessionStore(session.api, scope, snapshotDir) + + /** + * Per-session scratchlist cache (B-M4d), refetched when a session patch + * carries the `scratchlistUpdatedAt` trigger. + */ + val scratchlistStore: ScratchlistStore = ScratchlistStore( + api = session.api, + scope = scope, + invalidations = sessionStore.scratchlistInvalidations, + ) + + val machineStore: MachineStore = MachineStore(session.api, scope, snapshotDir) + + val lastSeenStore: LastSeenStore = LastSeenStore(scope, snapshotDir) + + val messageWindows: MessageWindowStores = MessageWindowStores( + api = session.api, + scope = scope, + snapshots = WindowSnapshots(File(snapshotDir, "windows")), + ) + + private val mutableToasts = MutableSharedFlow(extraBufferCapacity = 16) + + /** Hub-pushed in-app banners (never replayed); UI consumption lands in M4/M5. */ + val toasts: SharedFlow = mutableToasts.asSharedFlow() + + /** `POST /api/visibility` reporting, fed subscription ids by the handshake hook. */ + val visibilityReporter: VisibilityReporter = + VisibilityReporter(session.api::setVisibility, scope) + + val syncTargets: SyncTargets = StoreSyncTargets( + sessions = sessionStore, + machines = machineStore, + scope = scope, + messageWindows = messageWindows, + onToastEvent = { mutableToasts.tryEmit(it) }, + onHandshakeEvent = visibilityReporter::onHandshake, + ) + + val syncEventRouter: SyncEventRouter = SyncEventRouter(syncTargets) + + /** + * Hub-lifetime owner of the global SSE subscription (dual-subscription + * model): queued/consumed bookkeeping and list badges stay fresh no + * matter which screen is open. Torn down with [scope] on [close]. + */ + val globalPipe: GlobalSsePipe = GlobalSsePipe(sseEngine, syncTargets, scope).also { it.start() } + + /** + * Process foreground/background: defer/release SSE retries and report + * visibility to the hub (push suppression). Driven by `AppGraph` from + * `ProcessLifecycleOwner`. + */ + fun setLifecycleForeground(foreground: Boolean) { + sseEngine.setLifecycleForeground(foreground) + visibilityReporter.setForeground(foreground) + } + + /** + * Loads `/api/sessions/:id/generated-images/:imageId` (and any other hub + * URL) through the authed image client: JWT interceptor + silent 401 + * re-auth + the per-hub 256 MB disk cache (images are immutable + ETagged). + */ + val imageLoader: ImageLoader = ImageLoader.Builder(context) + .okHttpClient(session.imageClient) + .build() + + /** Absolute URL of a generated image, for [imageLoader]. */ + fun generatedImageUrl(sessionId: String, imageId: String): String = + "${session.hubUrl}/api/sessions/$sessionId/generated-images/$imageId" + + /** Absolute URL of a scratchlist attachment's raw bytes, for [imageLoader]. */ + fun scratchlistAttachmentUrl(sessionId: String, attachmentId: String): String = + "${session.hubUrl}/api/sessions/$sessionId/scratchlist/attachments/$attachmentId" + + /** Per-session composer drafts, keyed under this hub (process-wide DataStore). */ + val chatDrafts: ChatDrafts = DataStoreChatDrafts(context.chatDraftsDataStore, hubKey = session.hubUrl) + + override fun close() { + scope.cancel() + imageLoader.shutdown() + session.close() + } + + private companion object { + /** + * Filesystem-safe per-hub directory name. Distinct hubs must map to + * distinct directories (OkHttp caches require exclusive dirs); + * origins differing only in `[^A-Za-z0-9._-]` characters cannot + * collide because those are exactly `://` and `:`. + */ + fun dirNameFor(hubUrl: String): String = + hubUrl.replace(Regex("[^A-Za-z0-9._-]"), "_") + } +} + +/** + * Adapts [HubSession.ensureFreshToken] to the engine's [SseTokenProvider]. + * + * `forceRefresh` (the hub just 401'd the previous token) drops both JWT + * caches — the persisted copy and the authenticator's in-memory one — so + * `ensureFreshToken` has to do a genuine `POST /api/auth` exchange instead of + * returning a token the hub already rejected (rotated jwt-secret, clock skew). + */ +class SessionTokenProvider( + private val session: HubSession, + private val credentialStore: CredentialStore, +) : SseTokenProvider { + + override suspend fun freshToken(forceRefresh: Boolean): String? { + if (forceRefresh) { + withContext(Dispatchers.IO) { + credentialStore.get(session.hubUrl)?.let { credentials -> + credentialStore.set(credentials.copy(jwt = null, jwtObtainedAtMs = null)) + } + } + session.authenticator.clearCachedJwt() + } + return session.ensureFreshToken() + } +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/di/LocaleContexts.kt b/android/app/src/main/kotlin/app/hapi/companion/di/LocaleContexts.kt new file mode 100644 index 0000000000..82f9b650db --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/di/LocaleContexts.kt @@ -0,0 +1,24 @@ +package app.hapi.companion.di + +import android.content.Context +import android.content.res.Configuration +import android.os.LocaleList +import app.hapi.companion.feature.settings.AppLanguage +import java.util.Locale + +/** + * Wrap [this] so resource lookups honor the in-app language choice (B-M5a). + * + * Needed for surfaces that resolve strings from the **application** context — + * FCM notifications, WorkManager result updates, notification-action + * receivers: on API < 33 appcompat's per-app locales only retarget + * AppCompatActivity contexts (on 33+ the framework covers everything, and + * this wrap is a harmless no-op re-application). [AppLanguage.SYSTEM] returns + * the context unchanged. + */ +fun Context.localizedForAppLanguage(language: AppLanguage): Context { + if (language == AppLanguage.SYSTEM) return this + val configuration = Configuration(resources.configuration) + configuration.setLocales(LocaleList(Locale.forLanguageTag(language.localeTags))) + return createConfigurationContext(configuration) +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/fcm/HapiFirebaseMessagingService.kt b/android/app/src/main/kotlin/app/hapi/companion/fcm/HapiFirebaseMessagingService.kt new file mode 100644 index 0000000000..23738941c2 --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/fcm/HapiFirebaseMessagingService.kt @@ -0,0 +1,44 @@ +package app.hapi.companion.fcm + +import app.hapi.companion.HapiApp +import app.hapi.companion.di.AppGraph +import app.hapi.companion.di.localizedForAppLanguage +import app.hapi.data.push.PushPayload +import app.hapi.data.push.shouldSuppressPush +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage + +/** + * FCM entry point (B-M4a). Messages are **data-only** by contract + * (`docs/api/native-companion-contract.md`) — a `notification` block would + * stop `onMessageReceived` from running in the background — so every render + * decision is client-side: [PushPayload] parses/routes, [PushNotifications] + * builds, and the one suppression rule ([shouldSuppressPush]) skips the OS + * notification only when the app is foreground *with that session's chat + * open* (the in-app SSE stream is already showing the event). + * + * Without a Firebase config this service is inert — no token, no delivery — + * and [app.hapi.companion.push.PushBinding] keeps the rest of the push + * surface no-op'd to match. + */ +class HapiFirebaseMessagingService : FirebaseMessagingService() { + + private val appGraph: AppGraph + get() = (application as HapiApp).appGraph + + /** Token minted or rotated: (re-)register it with every paired hub. */ + override fun onNewToken(token: String) { + appGraph.onPushTokenRotated(token) + } + + override fun onMessageReceived(message: RemoteMessage) { + val payload = PushPayload.parse(message.data) ?: return + val graph = appGraph + if (shouldSuppressPush(graph.foreground, graph.openChatSessionId.value, payload.sessionId)) { + return + } + // In-app language (B-M5a): notification strings resolve from this + // service context, which per-app locales miss on API < 33. + PushNotifications.show(localizedForAppLanguage(graph.appLanguage.value), payload) + } +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/fcm/NotificationActionReceiver.kt b/android/app/src/main/kotlin/app/hapi/companion/fcm/NotificationActionReceiver.kt new file mode 100644 index 0000000000..39fb0fc8e2 --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/fcm/NotificationActionReceiver.kt @@ -0,0 +1,141 @@ +package app.hapi.companion.fcm + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.core.app.RemoteInput +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequest +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.OutOfQuotaPolicy +import androidx.work.WorkManager +import androidx.work.workDataOf +import app.hapi.companion.HapiApp +import app.hapi.companion.R +import app.hapi.companion.di.localizedForAppLanguage +import java.util.UUID +import java.util.concurrent.TimeUnit + +/** + * Handles notification action taps (Allow / Deny / Reply / Dismiss). The + * receiver itself does no I/O — it flips the notification to an in-progress + * state and hands the REST call to an **expedited** WorkManager worker, so + * the action survives process death, offline gaps, and JWT expiry (the + * worker re-auths through the stored credentials). + */ +class NotificationActionReceiver : BroadcastReceiver() { + + override fun onReceive(context: Context, intent: Intent) { + val sessionId = intent.getStringExtra(EXTRA_SESSION_ID) ?: return + val tag = intent.getStringExtra(EXTRA_TAG) ?: return + val channelId = intent.getStringExtra(EXTRA_CHANNEL_ID) ?: return + val title = intent.getStringExtra(EXTRA_TITLE) ?: "" + + // In-app language (B-M5a): progress strings resolve from the receiver + // context, which per-app locales miss on API < 33. + val localized = (context.applicationContext as? HapiApp) + ?.appGraph?.appLanguage?.value + ?.let(context::localizedForAppLanguage) ?: context + + when (intent.action) { + ACTION_APPROVE, ACTION_DENY -> { + val requestId = intent.getStringExtra(EXTRA_REQUEST_ID) ?: return + val approve = intent.action == ACTION_APPROVE + PushNotifications.showActionProgress( + localized, tag, sessionId, channelId, title, + localized.getString(if (approve) R.string.notif_allowing else R.string.notif_denying), + ) + enqueueExpedited( + context, + // One work item per decision — a second tap (or the other + // button racing) keeps the first decision. + uniqueName = "push-permission-$sessionId-$requestId", + request = OneTimeWorkRequestBuilder() + .setInputData( + workDataOf( + PermissionActionWorker.KEY_SESSION_ID to sessionId, + PermissionActionWorker.KEY_REQUEST_ID to requestId, + PermissionActionWorker.KEY_APPROVE to approve, + PermissionActionWorker.KEY_TAG to tag, + PermissionActionWorker.KEY_CHANNEL_ID to channelId, + PermissionActionWorker.KEY_TITLE to title, + ) + ), + ) + } + + ACTION_REPLY -> { + val text = RemoteInput.getResultsFromIntent(intent) + ?.getCharSequence(PushNotifications.KEY_REMOTE_INPUT) + ?.toString()?.trim() + if (text.isNullOrEmpty()) { + // Empty reply: nothing to send; clear the inline spinner. + PushNotifications.cancel(context, tag) + return + } + PushNotifications.showActionProgress( + localized, tag, sessionId, channelId, title, + localized.getString(R.string.notif_sending), + ) + val localId = UUID.randomUUID().toString() + enqueueExpedited( + context, + uniqueName = "push-reply-$localId", + request = OneTimeWorkRequestBuilder() + .setInputData( + workDataOf( + SendMessageWorker.KEY_SESSION_ID to sessionId, + SendMessageWorker.KEY_TEXT to text, + SendMessageWorker.KEY_LOCAL_ID to localId, + SendMessageWorker.KEY_TAG to tag, + SendMessageWorker.KEY_CHANNEL_ID to channelId, + SendMessageWorker.KEY_TITLE to title, + ) + ), + ) + } + + ACTION_DISMISS -> PushNotifications.cancel(context, tag) + } + } + + private fun enqueueExpedited( + context: Context, + uniqueName: String, + request: OneTimeWorkRequest.Builder, + ) { + val work = request + // Expedited only where it rides JobScheduler (API 31+). Below 31 + // expedited work must run as a foreground service — the FGS + // permissions plus the Play Console declaration they drag in are + // not worth a few seconds of enqueue latency on 26–30. + .apply { + if (Build.VERSION.SDK_INT >= 31) { + setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + } + } + .setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, BACKOFF_SECONDS, TimeUnit.SECONDS) + .build() + WorkManager.getInstance(context).enqueueUniqueWork(uniqueName, ExistingWorkPolicy.KEEP, work) + } + + companion object { + const val ACTION_APPROVE = "app.hapi.companion.action.PERMISSION_APPROVE" + const val ACTION_DENY = "app.hapi.companion.action.PERMISSION_DENY" + const val ACTION_REPLY = "app.hapi.companion.action.NOTIFICATION_REPLY" + const val ACTION_DISMISS = "app.hapi.companion.action.NOTIFICATION_DISMISS" + + const val EXTRA_SESSION_ID = "app.hapi.companion.extra.SESSION_ID" + const val EXTRA_REQUEST_ID = "app.hapi.companion.extra.REQUEST_ID" + const val EXTRA_TAG = "app.hapi.companion.extra.TAG" + const val EXTRA_CHANNEL_ID = "app.hapi.companion.extra.CHANNEL_ID" + const val EXTRA_TITLE = "app.hapi.companion.extra.TITLE" + + private const val BACKOFF_SECONDS = 10L + } +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/fcm/NotificationChannels.kt b/android/app/src/main/kotlin/app/hapi/companion/fcm/NotificationChannels.kt new file mode 100644 index 0000000000..ea971bdc10 --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/fcm/NotificationChannels.kt @@ -0,0 +1,54 @@ +package app.hapi.companion.fcm + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import app.hapi.companion.R +import app.hapi.data.push.PushPayload + +/** + * The app's notification channels, created idempotently on app start + * (`HapiApp.onCreate`) so they exist before the first FCM message — channel + * routing itself lives in [PushPayload.channelId]: + * + * - `permission_requests` — HIGH: an agent is blocked on the operator; the + * heads-up + sound interruption is the point. + * - `ready` — DEFAULT: the agent finished and is waiting for input. + * - `task_notifications` — DEFAULT: task completed/failed; also the bucket + * for unknown types / contract versions (never heads-up for those). + * + * minSdk is 26, so the channel APIs are unconditionally available. Importance + * is only a creation-time default — operators can retune per channel in + * system settings, which is exactly why these are three separate channels. + */ +object NotificationChannels { + + fun ensureCreated(context: Context) { + val manager = context.getSystemService(NotificationManager::class.java) ?: return + manager.createNotificationChannels( + listOf( + NotificationChannel( + PushPayload.CHANNEL_PERMISSION_REQUESTS, + context.getString(R.string.channel_permission_requests), + NotificationManager.IMPORTANCE_HIGH, + ).apply { + description = context.getString(R.string.channel_permission_requests_desc) + }, + NotificationChannel( + PushPayload.CHANNEL_READY, + context.getString(R.string.channel_ready), + NotificationManager.IMPORTANCE_DEFAULT, + ).apply { + description = context.getString(R.string.channel_ready_desc) + }, + NotificationChannel( + PushPayload.CHANNEL_TASK_NOTIFICATIONS, + context.getString(R.string.channel_task_notifications), + NotificationManager.IMPORTANCE_DEFAULT, + ).apply { + description = context.getString(R.string.channel_task_notifications_desc) + }, + ) + ) + } +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/fcm/PermissionActionWorker.kt b/android/app/src/main/kotlin/app/hapi/companion/fcm/PermissionActionWorker.kt new file mode 100644 index 0000000000..164aebd729 --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/fcm/PermissionActionWorker.kt @@ -0,0 +1,97 @@ +package app.hapi.companion.fcm + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import app.hapi.companion.R +import app.hapi.companion.di.AppGraph +import app.hapi.companion.di.localizedForAppLanguage +import app.hapi.data.push.PushActionOutcome + +/** + * Delivers a notification Allow/Deny through the authed client + * (`POST /sessions/:id/permissions/:rid/approve|deny`, empty `{}` body). + * Constructed by `HapiWorkerFactory` with the process [AppGraph]; the actual + * hub resolution (active hub first, others on session-miss) lives in + * `PushActionRunner` (`:core:data`). + */ +class PermissionActionWorker( + context: Context, + params: WorkerParameters, + private val appGraph: AppGraph, +) : CoroutineWorker(context, params) { + + override suspend fun doWork(): Result { + val sessionId = inputData.getString(KEY_SESSION_ID) ?: return Result.failure() + val requestId = inputData.getString(KEY_REQUEST_ID) ?: return Result.failure() + val approve = inputData.getBoolean(KEY_APPROVE, true) + val tag = inputData.getString(KEY_TAG) ?: return Result.failure() + val channelId = inputData.getString(KEY_CHANNEL_ID) ?: return Result.failure() + val title = inputData.getString(KEY_TITLE).orEmpty() + + appGraph.awaitReady() // the persisted hub roster must be loaded first + + val runner = appGraph.pushActionRunner + val outcome = if (approve) runner.approve(sessionId, requestId) else runner.deny(sessionId, requestId) + + // In-app language (B-M5a): result strings resolve from the worker context. + val context = applicationContext.localizedForAppLanguage(appGraph.appLanguage.value) + return when (outcome) { + is PushActionOutcome.Success -> { + result(tag, sessionId, channelId, title, context.getString( + if (approve) R.string.notif_allowed else R.string.notif_denied + ), autoExpire = true) + Result.success() + } + is PushActionOutcome.AlreadyHandled -> { + result(tag, sessionId, channelId, title, context.getString(R.string.notif_already_handled), autoExpire = true) + Result.success() + } + is PushActionOutcome.SessionInactive -> { + result(tag, sessionId, channelId, title, context.getString(R.string.notif_session_inactive), autoExpire = false) + Result.failure() + } + PushActionOutcome.SessionNotFound -> { + result(tag, sessionId, channelId, title, context.getString(R.string.notif_session_not_found), autoExpire = false) + Result.failure() + } + is PushActionOutcome.Failed -> { + result(tag, sessionId, channelId, title, context.getString(R.string.notif_action_failed), autoExpire = false) + Result.failure() + } + PushActionOutcome.Transient -> { + if (runAttemptCount + 1 >= MAX_ATTEMPTS) { + result(tag, sessionId, channelId, title, context.getString(R.string.notif_action_failed), autoExpire = false) + Result.failure() + } else { + Result.retry() + } + } + } + } + + private fun result( + tag: String, + sessionId: String, + channelId: String, + title: String, + text: String, + autoExpire: Boolean, + ) { + PushNotifications.showActionResult( + applicationContext.localizedForAppLanguage(appGraph.appLanguage.value), + tag, sessionId, channelId, title, text, autoExpire, + ) + } + + companion object { + const val KEY_SESSION_ID = "sessionId" + const val KEY_REQUEST_ID = "requestId" + const val KEY_APPROVE = "approve" + const val KEY_TAG = "tag" + const val KEY_CHANNEL_ID = "channelId" + const val KEY_TITLE = "title" + + private const val MAX_ATTEMPTS = 5 + } +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/fcm/PushNotifications.kt b/android/app/src/main/kotlin/app/hapi/companion/fcm/PushNotifications.kt new file mode 100644 index 0000000000..853e1c9250 --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/fcm/PushNotifications.kt @@ -0,0 +1,243 @@ +package app.hapi.companion.fcm + +import android.app.Notification +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.app.RemoteInput +import app.hapi.companion.MainActivity +import app.hapi.companion.R +import app.hapi.data.push.PushPayload +import app.hapi.data.push.PushSeverity +import app.hapi.data.push.PushType + +/** + * Builds and updates the OS notifications behind FCM pushes (B-M4a). + * + * Identity: every notification for a session+type pair shares the tag + * `type-` ([PushPayload.notificationTag]) and one fixed id — a + * newer push *coalesces* (replaces) the previous one, and the action workers + * update the same slot through pending → done/failed states. + * + * Actions (contract v1, known types only): + * - `permission-request` → Allow / Deny → [NotificationActionReceiver] → + * expedited [PermissionActionWorker]. + * - `ready` / `task-notification` → inline Reply (RemoteInput) → + * [SendMessageWorker]; plus a mark-as-read style Dismiss. + * + * Unknown types / contract versions render title+body with tap-to-open only. + */ +object PushNotifications { + + /** Single id; per-notification identity comes from the tag. */ + private const val NOTIFICATION_ID = 0x4150 // 'HP' + + /** Internal intent route for notification taps (no public URI on purpose). */ + const val ACTION_OPEN_SESSION = "app.hapi.companion.action.OPEN_SESSION" + const val EXTRA_SESSION_ID = "app.hapi.companion.extra.SESSION_ID" + + const val KEY_REMOTE_INPUT = "hapi_reply" + + /** Severity accents per `hub/src/fcm/fcmService.ts` (blue/green/amber/red). */ + private fun severityColor(severity: PushSeverity?): Int? = when (severity) { + PushSeverity.INFO -> 0xFF3B82F6.toInt() + PushSeverity.SUCCESS -> 0xFF22C55E.toInt() + PushSeverity.WARNING -> 0xFFF59E0B.toInt() + PushSeverity.ERROR -> 0xFFEF4444.toInt() + null -> null + } + + /** Renders [payload] (already past the suppress-when-open check). */ + fun show(context: Context, payload: PushPayload) { + val builder = baseBuilder(context, payload.channelId, payload.sessionId) + .setContentTitle(payload.displayTitle) + .setContentText(firstLine(payload.displayBody)) + .setStyle(NotificationCompat.BigTextStyle().bigText(payload.displayBody)) + .setSubText(payload.sessionName?.takeIf { it != payload.displayTitle }) + .setAutoCancel(true) + + severityColor(payload.severity)?.let(builder::setColor) + + if (payload.supportsActions) { + when (payload.type) { + PushType.PERMISSION_REQUEST -> addPermissionActions(context, builder, payload) + PushType.READY, PushType.TASK_NOTIFICATION -> addReplyActions(context, builder, payload) + null -> Unit + } + } + + notify(context, payload.notificationTag, builder.build()) + } + + // ------------------------------------------------------ action updates -- + + /** Replaces the notification with an in-progress state (actions removed). */ + fun showActionProgress( + context: Context, + tag: String, + sessionId: String?, + channelId: String, + title: String, + text: String, + ) { + val builder = baseBuilder(context, channelId, sessionId) + .setContentTitle(title) + .setContentText(text) + .setOnlyAlertOnce(true) + .setOngoing(true) + .setProgress(0, 0, true) + notify(context, tag, builder.build()) + } + + /** + * Terminal state after a worker finished. Success-ish results self-expire + * ([Notification.Builder.setTimeoutAfter]); failures stay until dismissed. + */ + fun showActionResult( + context: Context, + tag: String, + sessionId: String?, + channelId: String, + title: String, + text: String, + autoExpire: Boolean, + ) { + val builder = baseBuilder(context, channelId, sessionId) + .setContentTitle(title) + .setContentText(text) + .setStyle(NotificationCompat.BigTextStyle().bigText(text)) + .setOnlyAlertOnce(true) + .setAutoCancel(true) + if (autoExpire) builder.setTimeoutAfter(RESULT_TIMEOUT_MS) + notify(context, tag, builder.build()) + } + + fun cancel(context: Context, tag: String) { + NotificationManagerCompat.from(context).cancel(tag, NOTIFICATION_ID) + } + + // ------------------------------------------------------------- helpers -- + + private fun baseBuilder(context: Context, channelId: String, sessionId: String?) = + NotificationCompat.Builder(context, channelId) + .setSmallIcon(R.drawable.ic_stat_hapi) + .apply { sessionId?.let { setContentIntent(openSessionIntent(context, it)) } } + + private fun notify(context: Context, tag: String, notification: Notification) { + val manager = NotificationManagerCompat.from(context) + // POST_NOTIFICATIONS may be ungranted (API 33+) — notify() would be + // silently dropped anyway; skipping keeps lint honest and explicit. + if (!manager.areNotificationsEnabled()) return + try { + manager.notify(tag, NOTIFICATION_ID, notification) + } catch (_: SecurityException) { + // Permission revoked between check and post: nothing to show. + } + } + + /** + * Tap-through: an explicit intent into [MainActivity] carrying the + * session id — MainActivity feeds it to the existing navigation flow + * (`AppGraph.pendingOpenSessionId`). Deliberately *not* a URI deep link: + * this route is internal, nothing external should be able to speak it. + */ + private fun openSessionIntent(context: Context, sessionId: String): PendingIntent { + val intent = Intent(context, MainActivity::class.java) + .setAction(ACTION_OPEN_SESSION) + .putExtra(EXTRA_SESSION_ID, sessionId) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + return PendingIntent.getActivity( + context, + requestCode(sessionId, slot = 0), + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + } + + private fun addPermissionActions( + context: Context, + builder: NotificationCompat.Builder, + payload: PushPayload, + ) { + val requestId = payload.requestId ?: return + builder.addAction( + NotificationCompat.Action.Builder( + /* icon = */ 0, + context.getString(R.string.notif_action_allow), + actionIntent(context, NotificationActionReceiver.ACTION_APPROVE, payload, requestId, slot = 1), + ).build() + ) + builder.addAction( + NotificationCompat.Action.Builder( + /* icon = */ 0, + context.getString(R.string.notif_action_deny), + actionIntent(context, NotificationActionReceiver.ACTION_DENY, payload, requestId, slot = 2), + ).build() + ) + } + + private fun addReplyActions( + context: Context, + builder: NotificationCompat.Builder, + payload: PushPayload, + ) { + val remoteInput = RemoteInput.Builder(KEY_REMOTE_INPUT) + .setLabel(context.getString(R.string.notif_reply_hint)) + .build() + builder.addAction( + NotificationCompat.Action.Builder( + /* icon = */ 0, + context.getString(R.string.notif_action_reply), + actionIntent( + context, + NotificationActionReceiver.ACTION_REPLY, + payload, + requestId = null, + slot = 3, + // RemoteInput results must be attachable → mutable. + mutable = true, + ), + ).addRemoteInput(remoteInput).setAllowGeneratedReplies(false).build() + ) + builder.addAction( + NotificationCompat.Action.Builder( + /* icon = */ 0, + context.getString(R.string.notif_action_dismiss), + actionIntent(context, NotificationActionReceiver.ACTION_DISMISS, payload, requestId = null, slot = 4), + ).build() + ) + } + + private fun actionIntent( + context: Context, + action: String, + payload: PushPayload, + requestId: String?, + slot: Int, + mutable: Boolean = false, + ): PendingIntent { + val intent = Intent(context, NotificationActionReceiver::class.java) + .setAction(action) + .putExtra(NotificationActionReceiver.EXTRA_SESSION_ID, payload.sessionId) + .putExtra(NotificationActionReceiver.EXTRA_TAG, payload.notificationTag) + .putExtra(NotificationActionReceiver.EXTRA_CHANNEL_ID, payload.channelId) + .putExtra(NotificationActionReceiver.EXTRA_TITLE, payload.displayTitle) + .apply { requestId?.let { putExtra(NotificationActionReceiver.EXTRA_REQUEST_ID, it) } } + val mutability = if (mutable) PendingIntent.FLAG_MUTABLE else PendingIntent.FLAG_IMMUTABLE + return PendingIntent.getBroadcast( + context, + requestCode(payload.notificationTag, slot), + intent, + PendingIntent.FLAG_UPDATE_CURRENT or mutability, + ) + } + + /** Distinct PendingIntents per (tag, action-slot) so extras never collide. */ + private fun requestCode(key: String, slot: Int): Int = key.hashCode() * 31 + slot + + private fun firstLine(text: String): String = text.lineSequence().firstOrNull().orEmpty() + + private const val RESULT_TIMEOUT_MS = 5_000L +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/fcm/RegisterDeviceWorker.kt b/android/app/src/main/kotlin/app/hapi/companion/fcm/RegisterDeviceWorker.kt new file mode 100644 index 0000000000..f5c7cb575e --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/fcm/RegisterDeviceWorker.kt @@ -0,0 +1,62 @@ +package app.hapi.companion.fcm + +import android.content.Context +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import androidx.work.workDataOf +import app.hapi.companion.di.AppGraph +import java.util.concurrent.TimeUnit +import kotlin.coroutines.cancellation.CancellationException + +/** + * Retries a transiently failed `POST /api/devices/register` for one hub + * (enqueued by `DeviceRegistrar`'s retry seam). Network-constrained with + * exponential backoff; gives up after [MAX_ATTEMPTS] — the next app start / + * token rotation re-registers everything anyway. + */ +class RegisterDeviceWorker( + context: Context, + params: WorkerParameters, + private val appGraph: AppGraph, +) : CoroutineWorker(context, params) { + + override suspend fun doWork(): Result { + val hubUrl = inputData.getString(KEY_HUB_URL) ?: return Result.failure() + appGraph.awaitReady() + // The hub may have been signed out while this retry waited. + if (hubUrl !in appGraph.hubRegistry.state.value.hubs) return Result.success() + return try { + appGraph.deviceRegistrar.registerHubOnce(hubUrl) + Result.success() + } catch (cancellation: CancellationException) { + throw cancellation + } catch (_: Exception) { + if (runAttemptCount + 1 >= MAX_ATTEMPTS) Result.failure() else Result.retry() + } + } + + companion object { + const val KEY_HUB_URL = "hubUrl" + private const val MAX_ATTEMPTS = 6 + private const val BACKOFF_SECONDS = 30L + + /** One pending retry per hub; a fresh failure replaces the schedule. */ + fun enqueueRetry(context: Context, hubUrl: String) { + val work = OneTimeWorkRequestBuilder() + .setInputData(workDataOf(KEY_HUB_URL to hubUrl)) + .setConstraints( + Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build() + ) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, BACKOFF_SECONDS, TimeUnit.SECONDS) + .build() + WorkManager.getInstance(context) + .enqueueUniqueWork("push-register-$hubUrl", ExistingWorkPolicy.REPLACE, work) + } + } +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/fcm/SendMessageWorker.kt b/android/app/src/main/kotlin/app/hapi/companion/fcm/SendMessageWorker.kt new file mode 100644 index 0000000000..767cab5bb9 --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/fcm/SendMessageWorker.kt @@ -0,0 +1,90 @@ +package app.hapi.companion.fcm + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import app.hapi.companion.R +import app.hapi.companion.di.AppGraph +import app.hapi.companion.di.localizedForAppLanguage +import app.hapi.data.push.PushActionOutcome + +/** + * Delivers a notification inline reply (`POST /sessions/:id/messages` + * `{text, localId}`). The `localId` is minted once at tap time and reused + * across retries, so a retry after a half-delivered attempt cannot enqueue + * the message twice (the hub reconciles by localId) — same optimistic-send + * id the in-app composer uses. + */ +class SendMessageWorker( + context: Context, + params: WorkerParameters, + private val appGraph: AppGraph, +) : CoroutineWorker(context, params) { + + override suspend fun doWork(): Result { + val sessionId = inputData.getString(KEY_SESSION_ID) ?: return Result.failure() + val text = inputData.getString(KEY_TEXT) ?: return Result.failure() + val localId = inputData.getString(KEY_LOCAL_ID) ?: return Result.failure() + val tag = inputData.getString(KEY_TAG) ?: return Result.failure() + val channelId = inputData.getString(KEY_CHANNEL_ID) ?: return Result.failure() + val title = inputData.getString(KEY_TITLE).orEmpty() + + appGraph.awaitReady() + + // In-app language (B-M5a): result strings resolve from the worker context. + val context = applicationContext.localizedForAppLanguage(appGraph.appLanguage.value) + return when (val outcome = appGraph.pushActionRunner.sendMessage(sessionId, text, localId)) { + is PushActionOutcome.Success -> { + result(tag, sessionId, channelId, title, context.getString(R.string.notif_reply_sent), autoExpire = true) + Result.success() + } + is PushActionOutcome.SessionInactive -> { + result(tag, sessionId, channelId, title, context.getString(R.string.notif_session_inactive), autoExpire = false) + Result.failure() + } + PushActionOutcome.SessionNotFound -> { + result(tag, sessionId, channelId, title, context.getString(R.string.notif_session_not_found), autoExpire = false) + Result.failure() + } + is PushActionOutcome.AlreadyHandled, is PushActionOutcome.Failed -> { + // AlreadyHandled cannot happen for message sends; treat both + // as a hard rejection. + result(tag, sessionId, channelId, title, context.getString(R.string.notif_reply_failed), autoExpire = false) + Result.failure() + } + PushActionOutcome.Transient -> { + if (runAttemptCount + 1 >= MAX_ATTEMPTS) { + result(tag, sessionId, channelId, title, context.getString(R.string.notif_reply_failed), autoExpire = false) + Result.failure() + } else { + Result.retry() + } + } + } + } + + private fun result( + tag: String, + sessionId: String, + channelId: String, + title: String, + text: String, + autoExpire: Boolean, + ) { + PushNotifications.showActionResult( + applicationContext.localizedForAppLanguage(appGraph.appLanguage.value), + tag, sessionId, channelId, title, text, autoExpire, + ) + } + + companion object { + const val KEY_SESSION_ID = "sessionId" + const val KEY_TEXT = "text" + const val KEY_LOCAL_ID = "localId" + const val KEY_TAG = "tag" + const val KEY_CHANNEL_ID = "channelId" + const val KEY_TITLE = "title" + + private const val MAX_ATTEMPTS = 5 + } +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/chat/ChatBlockCard.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/ChatBlockCard.kt new file mode 100644 index 0000000000..c036afc724 --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/ChatBlockCard.kt @@ -0,0 +1,77 @@ +package app.hapi.companion.feature.chat + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Modifier +import app.hapi.companion.feature.chat.blocks.AgentEventBlockView +import app.hapi.companion.feature.chat.blocks.AgentReasoningBlockView +import app.hapi.companion.feature.chat.blocks.AgentTextBlockView +import app.hapi.companion.feature.chat.blocks.CliOutputBlockView +import app.hapi.companion.feature.chat.blocks.CodexReviewBlockView +import app.hapi.companion.feature.chat.blocks.GeneratedImageBlockView +import app.hapi.companion.feature.chat.blocks.ToolCallBlockView +import app.hapi.companion.feature.chat.blocks.ToolGroupBlockView +import app.hapi.companion.feature.chat.blocks.UserTextBlockView +import app.hapi.protocol.chat.AgentEventBlock +import app.hapi.protocol.chat.AgentReasoningBlock +import app.hapi.protocol.chat.AgentTextBlock +import app.hapi.protocol.chat.ChatBlock +import app.hapi.protocol.chat.CliOutputBlock +import app.hapi.protocol.chat.CodexReviewBlock +import app.hapi.protocol.chat.GeneratedImageBlock +import app.hapi.protocol.chat.ToolCallBlock +import app.hapi.protocol.chat.ToolGroupBlock +import app.hapi.protocol.chat.UserTextBlock +import app.hapi.protocol.chat.VisibleChatBlock +import coil.ImageLoader + +/** + * Hub-scoped media plumbing for chat blocks: the authed Coil loader plus the + * generated-image URL builder (both from `HubGraph`). Null loader (previews, + * tests) degrades to a filename placeholder. + */ +data class ChatMedia( + val imageLoader: ImageLoader?, + val generatedImageUrl: (imageId: String) -> String?, +) + +val LocalChatMedia = staticCompositionLocalOf { ChatMedia(imageLoader = null) { null } } + +/** Stable LazyColumn key. */ +val VisibleChatBlock.stableId: String + get() = when (this) { + is ChatBlock -> id + is ToolGroupBlock -> id + } + +/** LazyColumn contentType (recycling bucket). */ +val VisibleChatBlock.contentKind: String + get() = when (this) { + is ChatBlock -> kind + is ToolGroupBlock -> kind + } + +/** + * One thread entry: dispatches a reduced [VisibleChatBlock] to its card — + * the Compose analogue of the web's block-kind → component mapping + * (`HappyThread` user/assistant/system messages + `ToolCard`/`ToolGroupCard`). + * Also used recursively for tool-call children (sidechains). + */ +@Composable +fun ChatBlockCard( + block: VisibleChatBlock, + basePath: String?, + modifier: Modifier = Modifier, +) { + when (block) { + is UserTextBlock -> UserTextBlockView(block, modifier) + is AgentTextBlock -> AgentTextBlockView(block, modifier) + is AgentReasoningBlock -> AgentReasoningBlockView(block, modifier) + is AgentEventBlock -> AgentEventBlockView(block, modifier) + is CliOutputBlock -> CliOutputBlockView(block, modifier) + is GeneratedImageBlock -> GeneratedImageBlockView(block, modifier) + is CodexReviewBlock -> CodexReviewBlockView(block, modifier) + is ToolCallBlock -> ToolCallBlockView(block, basePath, modifier) + is ToolGroupBlock -> ToolGroupBlockView(block, basePath, modifier) + } +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/chat/ChatInteractions.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/ChatInteractions.kt new file mode 100644 index 0000000000..634ee7338c --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/ChatInteractions.kt @@ -0,0 +1,25 @@ +package app.hapi.companion.feature.chat + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.compositionLocalOf + +/** + * Interaction callbacks + optimistic overlay for chat blocks (B-M3ab), + * provided by [ChatScreen] via [LocalChatInteractions]. Null (previews, + * tests, read-only embeddings) keeps blocks in their M2 read-only rendering. + * + * [permissionOverrides] rides here (not per-block props) so deeply nested + * tool cards — groups, sidechains — see optimistic state without threading + * parameters through every layer. + */ +@Immutable +data class ChatInteractions( + /** Raw agent flavor id — selects the permission button set. */ + val flavor: String?, + val permissionOverrides: Map, + val resolvePermission: (requestId: String, action: PermissionAction) -> Unit, + val retryFailedMessage: (localId: String) -> Unit, +) + +/** Dynamic local: override churn invalidates readers only, not the whole tree. */ +val LocalChatInteractions = compositionLocalOf { null } diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/chat/ChatLinkHandler.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/ChatLinkHandler.kt new file mode 100644 index 0000000000..a54ae76d01 --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/ChatLinkHandler.kt @@ -0,0 +1,77 @@ +package app.hapi.companion.feature.chat + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.widget.Toast +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.core.net.toUri +import app.hapi.companion.R +import app.hapi.companion.ui.markdown.MarkdownLinkHandler +import app.hapi.protocol.markdown.HrefDecision + +/** + * The confirm-aware URL opener the M2d1 markdown module defers to this + * milestone: [HrefDecision.Allowed] dispatches immediately, + * [HrefDecision.ConfirmFirst] asks first (custom schemes), blocked never gets + * here. Workspace-file citations route to the session file viewer (B-M4c) via + * [onOpenFile] — the chat screen wires it to `chat/{id}/file` in full mode, + * passing the cited line along as a hint. + */ +@Composable +fun rememberChatLinkHandler( + onOpenFile: (path: String, line: Int?) -> Unit = { _, _ -> }, +): MarkdownLinkHandler { + val context = LocalContext.current + var confirmUrl by remember { mutableStateOf(null) } + + confirmUrl?.let { url -> + AlertDialog( + onDismissRequest = { confirmUrl = null }, + title = { Text(stringResource(R.string.chat_link_open_title)) }, + text = { Text(url) }, + confirmButton = { + TextButton( + onClick = { + confirmUrl = null + context.openUrl(url) + }, + ) { Text(stringResource(R.string.chat_link_open)) } + }, + dismissButton = { + TextButton(onClick = { confirmUrl = null }) { Text(stringResource(R.string.chat_cancel)) } + }, + ) + } + + return remember(context, onOpenFile) { + object : MarkdownLinkHandler { + override fun onFilePath(path: String, line: Int?) = onOpenFile(path, line) + + override fun onUrl(url: String, decision: HrefDecision) { + when (decision) { + is HrefDecision.Allowed -> context.openUrl(url) + is HrefDecision.ConfirmFirst -> confirmUrl = url + is HrefDecision.Blocked -> Unit + } + } + } + } +} + +private fun Context.openUrl(url: String) { + try { + startActivity(Intent(Intent.ACTION_VIEW, url.toUri())) + } catch (_: ActivityNotFoundException) { + Toast.makeText(this, getString(R.string.chat_link_no_app), Toast.LENGTH_SHORT).show() + } +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/chat/ChatScreen.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/ChatScreen.kt new file mode 100644 index 0000000000..bf7f1420a3 --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/ChatScreen.kt @@ -0,0 +1,855 @@ +package app.hapi.companion.feature.chat + +import android.Manifest +import android.content.pm.PackageManager +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.listSaver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import android.content.Context +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import app.hapi.companion.R +import app.hapi.companion.feature.chat.composer.DictationErrorKind +import app.hapi.companion.feature.chat.attachments.AttachmentPickerSheet +import app.hapi.companion.feature.chat.attachments.AttachmentPreparer +import app.hapi.companion.feature.chat.attachments.CameraCapture +import app.hapi.companion.feature.chat.attachments.PrepareResult +import app.hapi.companion.feature.chat.composer.ChatComposer +import app.hapi.companion.feature.chat.composer.DictationController +import app.hapi.companion.feature.chat.composer.DictationEvent +import app.hapi.companion.feature.chat.composer.DictationState +import app.hapi.companion.feature.chat.composer.QueuedMessagesBar +import app.hapi.companion.feature.files.FolderGlyph +import app.hapi.companion.feature.sessions.DeleteSessionDialog +import app.hapi.companion.feature.sessions.RenameSessionDialog +import app.hapi.companion.ui.components.AgentFlavorIcon +import app.hapi.companion.ui.markdown.LocalMarkdownLinkHandler +import app.hapi.companion.ui.theme.hapi +import app.hapi.protocol.chat.VisibleChatBlock +import java.io.File +import kotlinx.coroutines.launch + +/** How close to the oldest rendered block the viewport may get before paging. */ +private const val LOAD_OLDER_PREFETCH_ITEMS = 4 + +/** Pending camera capture across rotation/process death: uri + scratch path. */ +private val CameraCaptureSaver = listSaver( + save = { capture -> + if (capture == null) emptyList() else listOf(capture.uri.toString(), capture.file.absolutePath) + }, + restore = { saved -> + if (saved.size < 2) null else CameraCapture(Uri.parse(saved[0]), File(saved[1])) + }, +) + +/** + * The chat screen: `LazyColumn(reverseLayout = true)` over the reduced + * [VisibleChatBlock]s — newest at the bottom, stable ids as keys so scroll + * position survives pipeline re-runs, auto-stick to the tail only while + * already there (reverse-layout index-0 anchoring), a "new messages" pill + * otherwise, and a top-edge sentinel that pages older history in. + * + * B-M3ab adds the interaction chrome: composer + queued bar (bottom), + * permission actions (via [LocalChatInteractions]), the session config sheet + * (top-bar gear), and one-shot [ChatEvent] handling (supersede renavigation + + * snackbar notices). + * + * B-M3ce adds voice dictation (mic button, RECORD_AUDIO request, transcript + * append), the slash-command dropdown, and session ops: a top-bar overflow + * menu (Rename / Reopen / Delete) plus an inactive-session affordance bar + * above the composer (send already auto-resumes; Reopen is the explicit path). + * + * B-M3f adds composer attachments: the "+" sheet (photo library / camera / + * files), pick preparation ([AttachmentPreparer]: ContentResolver read + + * image downscale + 50 MB reject) feeding the ViewModel's upload tray, and + * the camera scratch capture (FileProvider Uri, `rememberSaveable` across + * rotation while the camera app is up). + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ChatScreen( + viewModel: ChatViewModel, + media: ChatMedia, + onBack: () -> Unit, + modifier: Modifier = Modifier, + onNavigateToSession: (String) -> Unit = {}, + /** null ⇒ mic button hidden (tests / previews without a controller). */ + dictation: DictationController? = null, + /** Top-bar folder icon → session files browser (B-M4c). */ + onOpenFiles: () -> Unit = {}, + /** Markdown file citations → file viewer (full mode; optional line hint). */ + onOpenFile: (path: String, line: Int?) -> Unit = { _, _ -> }, + /** null ⇒ no scratchlist top-bar entry (tests / previews). */ + onOpenScratchlist: (() -> Unit)? = null, +) { + val state by viewModel.uiState.collectAsState() + val composerState by viewModel.composer.collectAsState() + val queuedRows by viewModel.queuedRows.collectAsState() + val configState by viewModel.config.collectAsState() + val snackbarHostState = remember { SnackbarHostState() } + var configSheetOpen by remember { mutableStateOf(false) } + var renameDialogOpen by remember { mutableStateOf(false) } + var deleteDialogOpen by remember { mutableStateOf(false) } + + DisposableEffect(viewModel) { + viewModel.start() + onDispose { viewModel.stop() } + } + + val context = LocalContext.current + LaunchedEffect(viewModel, context) { + viewModel.events.collect { event -> + when (event) { + is ChatEvent.SessionSuperseded -> onNavigateToSession(event.sessionId) + ChatEvent.SessionDeleted -> onBack() + is ChatEvent.Notice -> snackbarHostState.showSnackbar(chatNoticeText(context, event.notice)) + } + } + } + + // ------------------------------------------------------------ dictation -- + val dictationState = dictation?.state?.collectAsState()?.value ?: DictationState.Idle + LaunchedEffect(dictation, context) { + dictation?.events?.collect { event -> + when (event) { + is DictationEvent.Transcribed -> viewModel.appendDictatedText(event.text) + DictationEvent.NoProvider -> snackbarHostState.showSnackbar( + context.getString(R.string.chat_notice_no_transcription), + ) + is DictationEvent.Error -> snackbarHostState.showSnackbar( + event.detail ?: context.getString( + when (event.kind) { + DictationErrorKind.StartFailed -> R.string.chat_dictation_start_failed + DictationErrorKind.HubUnreachable -> R.string.chat_dictation_hub_unreachable + DictationErrorKind.RecordingFailed -> R.string.chat_dictation_recording_failed + DictationErrorKind.NoAudio -> R.string.chat_dictation_no_audio + DictationErrorKind.TranscriptionFailed -> R.string.chat_dictation_failed + }, + ), + ) + } + } + } + val scope = rememberCoroutineScope() + val micPermissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { granted -> + if (granted) { + dictation?.toggle() + } else { + scope.launch { + snackbarHostState.showSnackbar(context.getString(R.string.chat_notice_mic_permission)) + } + } + } + val onDictationToggle: () -> Unit = toggle@{ + val controller = dictation ?: return@toggle + // Stopping never needs the permission; starting checks + requests it. + val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == + PackageManager.PERMISSION_GRANTED + when { + dictationState !is DictationState.Idle -> controller.toggle() + granted -> controller.toggle() + else -> micPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + } + } + val slashSuggestions by viewModel.slashSuggestions.collectAsState() + + // --------------------------------------------------------- attachments -- + val attachmentItems by viewModel.attachments.items.collectAsState() + var attachmentSheetOpen by remember { mutableStateOf(false) } + val preparer = remember { AttachmentPreparer(context) } + + /** Read + policy-apply one pick, then hand it to the upload tray. */ + suspend fun ingestUri(uri: Uri) { + when (val result = preparer.prepare(uri)) { + is PrepareResult.Ready -> viewModel.attachments.add(result.attachment) + is PrepareResult.TooLarge -> snackbarHostState.showSnackbar( + context.getString(R.string.chat_notice_attachment_too_large, result.filename), + ) + is PrepareResult.Unreadable -> snackbarHostState.showSnackbar( + context.getString(R.string.chat_notice_attachment_unreadable, result.filename), + ) + } + } + + fun ingestUris(uris: List) { + if (uris.isEmpty()) return + scope.launch { uris.forEach { ingestUri(it) } } + } + + val photoPickerLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.PickMultipleVisualMedia(), + ) { uris -> ingestUris(uris) } + val documentPickerLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.OpenMultipleDocuments(), + ) { uris -> ingestUris(uris) } + + // The camera app may rotate/kill us while open — keep the scratch target. + var pendingCapture by rememberSaveable(stateSaver = CameraCaptureSaver) { + mutableStateOf(null) + } + val takePictureLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.TakePicture(), + ) { success -> + val capture = pendingCapture + pendingCapture = null + if (capture == null) return@rememberLauncherForActivityResult + if (!success) { + capture.discard() + return@rememberLauncherForActivityResult + } + scope.launch { + ingestUri(capture.uri) + capture.discard() + } + } + + fun launchCamera() { + val capture = preparer.newCameraCapture() + pendingCapture = capture + takePictureLauncher.launch(capture.uri) + } + + val cameraPermissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { granted -> + if (granted) { + launchCamera() + } else { + scope.launch { + snackbarHostState.showSnackbar(context.getString(R.string.chat_notice_camera_permission)) + } + } + } + val onTakePhoto: () -> Unit = { + // The manifest declares CAMERA (QR pairing), which makes the runtime + // grant mandatory for ACTION_IMAGE_CAPTURE too. + val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == + PackageManager.PERMISSION_GRANTED + if (granted) launchCamera() else cameraPermissionLauncher.launch(Manifest.permission.CAMERA) + } + + val interactions = remember(state.flavor, state.permissionOverrides, viewModel) { + ChatInteractions( + flavor = state.flavor, + permissionOverrides = state.permissionOverrides, + resolvePermission = viewModel::resolvePermission, + retryFailedMessage = viewModel::retryFailedMessage, + ) + } + + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + TopAppBar( + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.chat_back)) + } + }, + title = { ChatTitle(state.header) }, + actions = { + // Two icons max (device feedback: four icons squeezed the + // title out) — gear for the frequent config switches, + // everything else in the overflow menu. + IconButton(onClick = { configSheetOpen = true }) { + Icon(Icons.Filled.Settings, contentDescription = stringResource(R.string.chat_open_settings)) + } + val scratchlistCount by viewModel.scratchlistCount.collectAsState() + SessionOverflowMenu( + active = state.header.active, + onOpenFiles = onOpenFiles, + scratchlistCount = scratchlistCount, + onOpenScratchlist = if (viewModel.scratchlistEnabled) onOpenScratchlist else null, + onRename = { renameDialogOpen = true }, + onReopen = viewModel::reopenSession, + onDelete = { deleteDialogOpen = true }, + // Draft-level action, relocated from the composer's + // own overflow (one less button in the input bar). + onParkDraft = if (viewModel.scratchlistEnabled && composerState.text.isNotBlank()) { + viewModel::parkComposerDraft + } else { + null + }, + ) + }, + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + bottomBar = { + // Edge-to-edge (enforced by targetSdk 35+): the bar owns its own + // system insets — nav-bar padding when the keyboard is closed, + // IME padding when open (inset consumption prevents doubling). + Column( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .imePadding(), + ) { + if (!state.header.active && !state.isInitialLoading && !state.loadFailed) { + InactiveSessionBar(onReopen = viewModel::reopenSession) + } + QueuedMessagesBar( + rows = queuedRows, + onSteer = viewModel::steerQueuedMessage, + onRetry = viewModel::retryIndeterminateMessage, + onEdit = viewModel::editQueuedMessage, + onCancel = viewModel::cancelQueuedMessage, + ) + ChatComposer( + state = composerState, + onTextChange = viewModel::setComposerText, + onSend = { viewModel.sendMessage() }, + onSendSteer = { viewModel.sendMessage(steer = true) }, + onAbort = viewModel::abortSession, + attachments = attachmentItems, + onAddAttachment = { attachmentSheetOpen = true }, + onAttachmentRetry = viewModel.attachments::retry, + onAttachmentRemove = viewModel.attachments::remove, + slashSuggestions = slashSuggestions, + onSlashCommandSelected = viewModel::selectSlashCommand, + dictation = if (dictation != null) dictationState else null, + onDictationToggle = onDictationToggle, + onDictationCancel = { dictation?.cancel() }, + ) + } + }, + ) { padding -> + CompositionLocalProvider( + LocalChatMedia provides media, + LocalMarkdownLinkHandler provides rememberChatLinkHandler(onOpenFile = onOpenFile), + LocalChatInteractions provides interactions, + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding), + ) { + state.warning?.let { warning -> + DegradedBanner(warning = warning, onRetry = viewModel::retry) + } + Box(modifier = Modifier.weight(1f)) { + when { + state.isInitialLoading -> InitialLoading() + state.loadFailed -> LoadFailed(onRetry = viewModel::retry) + state.blocks.isEmpty() -> EmptyChat() + else -> BlockList(state = state, onLoadOlder = viewModel::loadOlder) + } + } + } + } + } + + if (attachmentSheetOpen) { + AttachmentPickerSheet( + onDismiss = { attachmentSheetOpen = false }, + onPickPhotos = { + photoPickerLauncher.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageAndVideo), + ) + }, + onTakePhoto = onTakePhoto, + onPickFiles = { documentPickerLauncher.launch(arrayOf("*/*")) }, + ) + } + if (configSheetOpen) { + SessionConfigSheet( + config = configState, + onDismiss = { configSheetOpen = false }, + onSetPermissionMode = viewModel::setPermissionMode, + onSetModel = viewModel::setModel, + onSetEffort = viewModel::setEffort, + onLoadModelOptions = viewModel::loadModelOptions, + ) + } + if (renameDialogOpen) { + RenameSessionDialog( + initialName = state.header.name ?: state.header.title, + onConfirm = { name -> + renameDialogOpen = false + viewModel.renameSession(name) + }, + onDismiss = { renameDialogOpen = false }, + ) + } + if (deleteDialogOpen) { + DeleteSessionDialog( + sessionTitle = state.header.title, + onConfirm = { + deleteDialogOpen = false + viewModel.deleteSession() + }, + onDismiss = { deleteDialogOpen = false }, + ) + } +} + +/** + * Top-bar ⋮ menu: navigation entries first (Files always, Scratchlist with + * entry count when enabled), then Rename always; Reopen only for inactive + * sessions; Delete last. + */ +@Composable +private fun SessionOverflowMenu( + active: Boolean, + onRename: () -> Unit, + onReopen: () -> Unit, + onDelete: () -> Unit, + onOpenFiles: () -> Unit = {}, + /** Entry-count suffix on the scratchlist row. */ + scratchlistCount: Int = 0, + /** null ⇒ scratchlist row hidden (feature off / tests). */ + onOpenScratchlist: (() -> Unit)? = null, + /** null ⇒ hidden (scratchlist off or empty composer). */ + onParkDraft: (() -> Unit)? = null, +) { + var open by remember { mutableStateOf(false) } + IconButton(onClick = { open = true }) { + Icon(Icons.Filled.MoreVert, contentDescription = stringResource(R.string.chat_session_actions)) + } + DropdownMenu(expanded = open, onDismissRequest = { open = false }) { + DropdownMenuItem( + text = { Text(stringResource(R.string.chat_open_files)) }, + leadingIcon = { Icon(FolderGlyph, contentDescription = null) }, + onClick = { + open = false + onOpenFiles() + }, + ) + if (onOpenScratchlist != null) { + DropdownMenuItem( + text = { + Text( + if (scratchlistCount > 0) { + stringResource(R.string.chat_open_scratchlist_count, scratchlistCount) + } else { + stringResource(R.string.chat_open_scratchlist) + }, + ) + }, + onClick = { + open = false + onOpenScratchlist() + }, + ) + } + HorizontalDivider() + DropdownMenuItem( + text = { Text(stringResource(R.string.sessions_action_rename)) }, + onClick = { + open = false + onRename() + }, + ) + if (onParkDraft != null) { + DropdownMenuItem( + text = { Text(stringResource(R.string.chat_park_draft)) }, + onClick = { + open = false + onParkDraft() + }, + ) + } + if (!active) { + DropdownMenuItem( + text = { Text(stringResource(R.string.sessions_action_reopen)) }, + onClick = { + open = false + onReopen() + }, + ) + } + DropdownMenuItem( + text = { Text(stringResource(R.string.sessions_action_delete), color = MaterialTheme.colorScheme.error) }, + onClick = { + open = false + onDelete() + }, + ) + } +} + +/** + * Inactive-session affordance above the composer: sending auto-resumes + * (B-M3ab), Reopen is the explicit restart without a message. + */ +@Composable +private fun InactiveSessionBar(onReopen: () -> Unit) { + Surface( + color = MaterialTheme.colorScheme.surfaceContainerHigh, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.fillMaxWidth(), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringResource(R.string.chat_inactive_bar), + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .weight(1f) + .padding(start = 16.dp, top = 4.dp, bottom = 4.dp), + ) + TextButton(onClick = onReopen) { Text(stringResource(R.string.sessions_action_reopen)) } + } + } +} + +@Composable +private fun ChatTitle(header: ChatHeaderUi) { + Row(verticalAlignment = Alignment.CenterVertically) { + StatusDot(active = header.active, thinking = header.thinking) + Spacer(modifier = Modifier.width(8.dp)) + Column { + Text( + text = header.title, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + header.subtitle?.let { subtitle -> + Row(verticalAlignment = Alignment.CenterVertically) { + header.flavor?.let { flavor -> + // Hint-colored like the meta text (web: currentColor + // under --app-hint); color variants ignore the tint. + CompositionLocalProvider( + LocalContentColor provides MaterialTheme.colorScheme.onSurfaceVariant, + ) { + AgentFlavorIcon(flavor, modifier = Modifier.size(14.dp)) + } + Spacer(modifier = Modifier.width(4.dp)) + } + Text( + text = subtitle, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } +} + +@Composable +private fun StatusDot(active: Boolean, thinking: Boolean) { + val color = when { + thinking -> Color(0xFF34C759).copy(alpha = 0.6f) + active -> Color(0xFF34C759) + else -> MaterialTheme.colorScheme.outlineVariant + } + Box( + modifier = Modifier + .size(9.dp) + .background(color, CircleShape), + ) +} + +@Composable +private fun DegradedBanner(warning: String, onRetry: () -> Unit) { + Surface( + color = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.fillMaxWidth(), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = warning, + style = MaterialTheme.typography.labelMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .weight(1f) + .padding(start = 16.dp, top = 6.dp, bottom = 6.dp), + ) + TextButton(onClick = onRetry) { Text(stringResource(R.string.chat_retry)) } + } + } +} + +// ------------------------------------------------------------------- list -- + +@Composable +private fun BlockList(state: ChatUiState, onLoadOlder: () -> Unit) { + val listState = rememberLazyListState() + val scope = rememberCoroutineScope() + // Newest-first for reverseLayout: index 0 renders at the bottom. + val reversed = remember(state.blocks) { state.blocks.asReversed() } + + LoadOlderEffect(listState, state, onLoadOlder) + + Box(modifier = Modifier.fillMaxSize()) { + LazyColumn( + state = listState, + reverseLayout = true, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + items( + items = reversed, + key = { it.stableId }, + contentType = { it.contentKind }, + ) { block -> + ChatBlockCard(block = block, basePath = state.basePath) + } + if (state.hasMore || state.isLoadingOlder) { + item(key = "older-history", contentType = "older-history") { + OlderHistoryRow(isLoading = state.isLoadingOlder) + } + } + } + + NewMessagesPill( + listState = listState, + reversed = reversed, + sessionId = state.sessionId, + onClick = { scope.launch { listState.animateScrollToItem(0) } }, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 12.dp), + ) + } +} + +/** Sentinel: when the viewport nears the oldest rendered block, page older history. */ +@Composable +private fun LoadOlderEffect(listState: LazyListState, state: ChatUiState, onLoadOlder: () -> Unit) { + val nearOldest by remember(listState) { + derivedStateOf { + val info = listState.layoutInfo + val lastVisible = info.visibleItemsInfo.lastOrNull()?.index ?: return@derivedStateOf false + lastVisible >= info.totalItemsCount - 1 - LOAD_OLDER_PREFETCH_ITEMS + } + } + LaunchedEffect(nearOldest, state.hasMore, state.isLoadingOlder, state.isSyncingTail) { + if (nearOldest && state.hasMore && !state.isLoadingOlder && !state.isSyncingTail) { + onLoadOlder() + } + } +} + +@Composable +private fun OlderHistoryRow(isLoading: Boolean) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + if (isLoading) { + CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.chat_loading_older), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.hapi.hint, + ) + } else { + Text( + text = "· · ·", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.hapi.hint, + ) + } + } +} + +/** + * "N new messages ↓" pill: appears when new blocks land while the reader is + * scrolled up. At the bottom (reverse-layout index 0, offset 0) the list + * auto-sticks and the pill stays hidden. + */ +@Composable +private fun NewMessagesPill( + listState: LazyListState, + reversed: List, + sessionId: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val atBottom by remember(listState) { + derivedStateOf { + listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0 + } + } + var newestSeenId by remember(sessionId) { mutableStateOf(null) } + val newestId = reversed.firstOrNull()?.stableId + + LaunchedEffect(atBottom, newestId) { + if (atBottom) newestSeenId = newestId + } + + val unseenCount = if (atBottom) { + 0 + } else { + val seenId = newestSeenId + if (seenId == null) 0 + else reversed.indexOfFirst { it.stableId == seenId }.coerceAtLeast(0) + } + if (unseenCount == 0) return + + Surface( + color = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + shape = CircleShape, + shadowElevation = 4.dp, + onClick = onClick, + modifier = modifier, + ) { + Text( + text = if (unseenCount == 1) { + stringResource(R.string.chat_new_messages_one) + } else { + stringResource(R.string.chat_new_messages_many, unseenCount) + }, + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.padding(horizontal = 14.dp, vertical = 7.dp), + ) + } +} + +// ----------------------------------------------------------------- states -- + +@Composable +private fun InitialLoading() { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + CircularProgressIndicator() + Spacer(modifier = Modifier.size(12.dp)) + Text( + text = stringResource(R.string.chat_loading_messages), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun LoadFailed(onRetry: () -> Unit) { + Column( + modifier = Modifier.fillMaxSize().padding(32.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(text = stringResource(R.string.chat_load_failed_title), style = MaterialTheme.typography.titleMedium) + Spacer(modifier = Modifier.size(8.dp)) + Text( + text = stringResource(R.string.chat_load_failed_hint), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.size(12.dp)) + TextButton(onClick = onRetry) { Text(stringResource(R.string.chat_retry)) } + } +} + +@Composable +private fun EmptyChat() { + Column( + modifier = Modifier.fillMaxSize().padding(32.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(text = stringResource(R.string.chat_empty_title), style = MaterialTheme.typography.titleMedium) + Spacer(modifier = Modifier.size(8.dp)) + Text( + text = stringResource(R.string.chat_empty_hint), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +// ------------------------------------------------------------- notices -- + +/** + * Localize a [ChatNotice] (B-M5a). Server/exception detail text, when + * present, is shown verbatim — matching the pre-i18n `message ?: fallback` + * behavior — so hub-side wording is never mistranslated. + */ +internal fun chatNoticeText(context: Context, notice: ChatNotice): String = when (notice) { + ChatNotice.DraftParked -> context.getString(R.string.chat_notice_draft_parked) + ChatNotice.ScratchlistFull -> context.getString(R.string.chat_notice_scratchlist_full) + ChatNotice.ScratchlistParkFailed -> context.getString(R.string.chat_notice_park_failed) + ChatNotice.AttachmentsUploading -> context.getString(R.string.chat_notice_attachments_uploading) + ChatNotice.ResumeFailed -> context.getString(R.string.chat_notice_resume_failed) + ChatNotice.QueuedEditKeptDraft -> context.getString(R.string.chat_notice_edit_kept_draft) + ChatNotice.QueuedAlreadyDelivered -> context.getString(R.string.chat_notice_already_delivered) + ChatNotice.PermissionAlreadyHandled -> context.getString(R.string.chat_notice_request_already_handled) + ChatNotice.DeleteConflictActive -> context.getString(R.string.sessions_error_delete_active) + is ChatNotice.AbortFailed -> notice.detail ?: context.getString(R.string.chat_notice_abort_failed) + is ChatNotice.RenameFailed -> notice.detail ?: context.getString(R.string.chat_notice_rename_failed) + is ChatNotice.DeleteFailed -> notice.detail ?: context.getString(R.string.chat_notice_delete_failed) + is ChatNotice.ReopenFailed -> notice.detail ?: context.getString(R.string.sessions_reopen_failed_fallback) + is ChatNotice.CancelQueuedFailed -> notice.detail ?: context.getString(R.string.chat_notice_cancel_failed) + is ChatNotice.SteerFailed -> notice.detail ?: context.getString(R.string.chat_notice_steer_failed) + is ChatNotice.PermissionRequestFailed -> notice.detail ?: context.getString(R.string.chat_notice_request_failed) + is ChatNotice.ModelsLoadFailed -> notice.detail ?: context.getString(R.string.chat_notice_models_failed) + is ChatNotice.ConfigUpdateFailed -> notice.detail ?: context.getString(R.string.chat_notice_config_failed) +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/chat/ChatViewModel.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/ChatViewModel.kt new file mode 100644 index 0000000000..83d0a15b3b --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/ChatViewModel.kt @@ -0,0 +1,1551 @@ +package app.hapi.companion.feature.chat + +import app.hapi.companion.feature.chat.attachments.ComposerAttachments +import app.hapi.companion.feature.chat.composer.ChatDrafts +import app.hapi.companion.feature.chat.composer.SlashCommands +import app.hapi.companion.feature.chat.composer.appendTranscript +import app.hapi.companion.feature.sessions.SessionListViewModel +import app.hapi.companion.feature.sessions.formatReopenError +import app.hapi.data.api.ApiError +import app.hapi.data.api.ChatSessionApi +import app.hapi.data.sse.SseEngine +import app.hapi.data.sse.SseSubscriptionKey +import app.hapi.data.sse.SyncEventRouter +import app.hapi.data.sse.SyncTargets +import app.hapi.data.store.LastSeenStore +import app.hapi.data.store.MachineListStore +import app.hapi.data.store.MessageWindowStore +import app.hapi.data.store.MessageWindowStores +import app.hapi.data.store.ScratchlistCreateResult +import app.hapi.data.store.SessionDetailStore +import app.hapi.data.store.SessionScratchlist +import app.hapi.protocol.catalog.CatalogOption +import app.hapi.protocol.catalog.Flavors +import app.hapi.protocol.catalog.ModelCatalog +import app.hapi.protocol.catalog.PermissionMode +import app.hapi.protocol.catalog.PermissionModes +import app.hapi.protocol.chat.NormalizedMessage +import app.hapi.protocol.chat.ToolGroupBlock +import app.hapi.protocol.chat.ToolGroupingOptions +import app.hapi.protocol.chat.VisibleChatBlock +import app.hapi.protocol.chat.buildVisibleChatBlocks +import app.hapi.protocol.chat.getInputStringAny +import app.hapi.protocol.chat.normalizeDecryptedMessage +import app.hapi.protocol.chat.reduceChatBlocks +import app.hapi.protocol.window.MessageStatus +import app.hapi.protocol.window.MessageWindowState +import app.hapi.protocol.window.WindowMessage +import app.hapi.protocol.window.asWindowMessage +import app.hapi.protocol.wire.AgentState +import app.hapi.protocol.wire.ApprovePermissionRequest +import app.hapi.protocol.wire.AttachmentMetadata +import app.hapi.protocol.wire.CodexModelSummary +import app.hapi.protocol.wire.HapiJson +import app.hapi.protocol.wire.Machine +import app.hapi.protocol.wire.SendMessageRequest +import app.hapi.protocol.wire.Session +import app.hapi.protocol.wire.SessionSummary +import app.hapi.protocol.wire.SlashCommand +import app.hapi.protocol.wire.arrayOrNull +import app.hapi.protocol.wire.objOrNull +import app.hapi.protocol.wire.stringOrNull +import java.util.UUID +import kotlin.coroutines.cancellation.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.onSubscription +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.transform +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +/** Chat top-bar model: title cascade + status + meta line. */ +data class ChatHeaderUi( + val title: String, + /** "Flavor · machine · worktree/path" meta line; null when nothing known. */ + val subtitle: String?, + /** Raw `metadata.flavor` — drives the brand icon next to the meta line. */ + val flavor: String? = null, + /** Raw custom `metadata.name` (rename-dialog prefill; the title cascade may show more). */ + val name: String? = null, + val active: Boolean, + val thinking: Boolean, +) + +/** Optimistic-permission UI state layered over the reduced blocks (M3b). */ +enum class PermissionRowOverride { + /** Decision POSTed; waiting for the agentState patch to settle it. */ + Resolving, + + /** The hub said the request is no longer pending (404/409) — benign. */ + AlreadyHandled, +} + +/** What [ChatScreen] renders. */ +data class ChatUiState( + val sessionId: String, + val header: ChatHeaderUi, + /** Raw agent flavor id (`claude`, `codex`, …); drives permission button sets. */ + val flavor: String?, + /** Workspace root, for path display in tool cards. */ + val basePath: String?, + val blocks: List, + /** Per-request optimistic permission state, keyed by request id. */ + val permissionOverrides: Map, + val hasMore: Boolean, + val isLoadingOlder: Boolean, + val isSyncingTail: Boolean, + /** First sync still running and nothing (snapshot included) to show yet. */ + val isInitialLoading: Boolean, + /** Initial load produced nothing and the last attempt failed → error state. */ + val loadFailed: Boolean, + /** Tail sync warning — the connection/staleness banner. */ + val warning: String?, + /** Bumps on tail-side content changes; drives the new-messages pill. */ + val tailRevision: Long, +) + +/** Composer bar state (M3a). */ +data class ComposerUiState( + val text: String, + /** A send (or its inactive-session resume) is in flight — spinner on the send button. */ + val isSending: Boolean, + /** A turn is active: long-press send offers Steer, and Abort is shown. */ + val canSteer: Boolean, +) + +/** One row of the queued-messages bar (uninvoked sends). */ +data class QueuedRowUi( + val id: String, + val localId: String?, + val text: String, + val attachmentNames: List, + val scheduledAt: Long?, + /** + * Server echo has landed (`id != localId`) and no queued operation is in + * flight — Cancel/Edit act only then (web `computeCanCancel`). + */ + val canAct: Boolean, + /** Steer offered: turn active, not future-scheduled, actionable. */ + val canSteer: Boolean, + /** Native delivery outcome is unknown; show explicit retry instead of normal Steer. */ + val indeterminate: Boolean = false, +) + +/** Session config sheet model (M3b switching). */ +data class SessionConfigUi( + val flavor: String?, + val active: Boolean, + /** Terminal-controlled sessions reject config posts with 409. */ + val controlledByUser: Boolean, + /** Raw wire mode; may be outside [permissionModes] (render verbatim). */ + val permissionMode: String?, + /** Catalog modes for this flavor; empty → hide the section (pi). */ + val permissionModes: List, + val model: String?, + /** null → hide the model section (flavor without a known catalog). */ + val modelOptions: List?, + /** True while the codex model catalog loads (sheet shows a spinner row). */ + val modelOptionsLoading: Boolean, + /** Claude `effort` or codex `modelReasoningEffort`, whichever applies. */ + val effort: String?, + /** null → hide the effort section. */ + val effortOptions: List?, +) + +/** One-shot side effects for the screen. */ +sealed interface ChatEvent { + /** Resume/reopen returned a different session id — renavigate to it. */ + data class SessionSuperseded(val sessionId: String) : ChatEvent + + /** The session was deleted — leave the chat screen. */ + data object SessionDeleted : ChatEvent + + /** Transient failure/notice for a snackbar (resolved to a string at the UI layer). */ + data class Notice(val notice: ChatNotice) : ChatEvent +} + +/** + * Semantic snackbar notices (B-M5a): the ViewModel stays string-free so the + * UI layer localizes; `detail` carries server/exception text and, when + * present, is shown verbatim (matching the previous `message ?: fallback` + * behavior). + */ +sealed interface ChatNotice { + data object DraftParked : ChatNotice + data object ScratchlistFull : ChatNotice + data object ScratchlistParkFailed : ChatNotice + data object AttachmentsUploading : ChatNotice + data object ResumeFailed : ChatNotice + data object QueuedEditKeptDraft : ChatNotice + data object QueuedAlreadyDelivered : ChatNotice + data object PermissionAlreadyHandled : ChatNotice + + /** `DELETE` answered 409 — the session is still active (archive first). */ + data object DeleteConflictActive : ChatNotice + + data class AbortFailed(val detail: String?) : ChatNotice + data class RenameFailed(val detail: String?) : ChatNotice + data class DeleteFailed(val detail: String?) : ChatNotice + data class ReopenFailed(val detail: String?) : ChatNotice + data class CancelQueuedFailed(val detail: String?) : ChatNotice + data class SteerFailed(val detail: String?) : ChatNotice + data class PermissionRequestFailed(val detail: String?) : ChatNotice + data class ModelsLoadFailed(val detail: String?) : ChatNotice + data class ConfigUpdateFailed(val detail: String?) : ChatNotice +} + +/** A permission decision the UI can request (bodies mirror `PermissionFooter.tsx`). */ +sealed interface PermissionAction { + /** Plain allow — `{}` (claude family) or `{"decision":"approved"}` (codex family). */ + data object Allow : PermissionAction + + /** Claude: `{"allowTools":[…]}`; everyone else: `{"decision":"approved_for_session"}`. */ + data object AllowForSession : PermissionAction + + /** Claude edit tools: `{"mode":"acceptEdits"}`. */ + data object AllowAllEdits : PermissionAction + + /** Plain deny — `{}`. */ + data object Deny : PermissionAction + + /** Codex family: deny with `{"decision":"abort"}`. */ + data object Abort : PermissionAction + + /** AskUserQuestion: flat `{"": ["label", …]}`. */ + data class FlatAnswers(val answers: Map>) : PermissionAction + + /** request_user_input: nested `{"": {"answers": […]}}`. */ + data class NestedAnswers(val answers: Map>) : PermissionAction +} + +/** + * Per-session chat state machine. The M2 read-only slice (SSE pipe + window + + * pipeline, see below) plus the B-M3ab interaction layer: + * + * - **Composer** ([composer]/[setComposerText]/[sendMessage]): optimistic send + * (`appendOptimistic` → POST → status settle), queue-by-default with an + * explicit steer intent, failed rows retried via [retryFailedMessage]; + * `session_inactive` (409) auto-resumes once and retries, following a + * superseding session id with a window seed + draft move + + * [ChatEvent.SessionSuperseded]. Drafts persist per session via [ChatDrafts]. + * - **Queued bar** ([queuedRows]): uninvoked sends with Cancel (DELETE, + * invoked-race ingested), Edit (cancel + prefill) and Steer (POST steer). + * - **Permissions** ([resolvePermission]): flavor-exact approve/deny bodies, + * optimistic [PermissionRowOverride]s settled by the agentState patch. + * - **Config** ([config]/[setPermissionMode]/[setModel]/[setEffort]): catalog + * pickers with optimistic detail updates, rolled back to server truth on + * error; codex model catalog fetched per session ([loadModelOptions]). + * + * B-M3f adds: + * - **Attachments** ([attachments], a [ComposerAttachments] tray): picks are + * prepared by the screen (ContentResolver read + image downscale) and + * uploaded immediately; [sendMessage] refuses while any chip is unsettled, + * then rides the Ready set as `SendMessageRequest.attachments` — the + * optimistic row carries them so the user bubble shows thumbnails at once, + * and [retryFailedMessage] re-extracts them from the row's wire content. + * Attachments are NOT part of drafts (v1 simplification vs the web's + * IndexedDB attachment drafts): leaving the chat for good + * ([discardAttachments], holder `onCleared`) drops un-sent chips after a + * best-effort hub delete. + * + * B-M3ce adds: + * - **Slash commands** ([slashSuggestions]/[selectSlashCommand]): `/token` + * composer input opens the dropdown; sources = `metadata.slashCommands` + * names + the lazily-fetched `GET /slash-commands` list (RPC wins dedupe). + * - **Dictation hand-off** ([appendDictatedText]): the screen-owned + * `DictationController` emits transcripts; they append via `appendTranscript`. + * - **Session ops** ([renameSession]/[deleteSession]/[reopenSession]): + * store-optimistic rename, delete (409-aware) with [ChatEvent.SessionDeleted], + * reopen reusing the supersede path (window seed + draft move + + * [ChatEvent.SessionSuperseded]) and [formatReopenError] for 422s. + * + * M2 core (unchanged): owns the session-scope SSE subscription while + * [start]ed (dual-subscription model: `HubGraph` owns the global pipe) and + * routes engine events into the shared [SyncTargets]; opens the + * [MessageWindowStore], activates it, tail-syncs and reconciles queued state; + * runs the normalize → reduce → toolGroups pipeline over the window + the + * detail's `agentState` on [pipelineDispatcher], throttled to one run per + * [pipelineIntervalMs]; stamps the [LastSeenStore] watermark. + * + * Plain constructor — JVM tests drive it with fake stores and a scripted + * transport; Navigation hosts it behind a lifecycle-aware holder. + */ +class ChatViewModel( + val sessionId: String, + private val api: ChatSessionApi, + private val sessionStore: SessionDetailStore, + private val machineStore: MachineListStore, + private val lastSeenStore: LastSeenStore, + private val messageWindows: MessageWindowStores, + private val sseEngine: SseEngine, + syncTargets: SyncTargets, + private val scope: CoroutineScope, + private val drafts: ChatDrafts? = null, + /** null ⇒ scratchlist UI hidden (badge, park) — tests/previews without a store. */ + private val scratchlist: SessionScratchlist? = null, + private val pipelineDispatcher: CoroutineDispatcher = Dispatchers.Default, + private val pipelineIntervalMs: Long = PIPELINE_INTERVAL_MS, + private val draftSaveDebounceMs: Long = DRAFT_SAVE_DEBOUNCE_MS, + private val now: () -> Long = System::currentTimeMillis, + /** Web `makeClientSideId('local')` twin; injectable for deterministic tests. */ + private val localIdGenerator: () -> String = { "local-${UUID.randomUUID()}" }, +) { + private val router = SyncEventRouter(syncTargets) + private val subscriptionKey = SseSubscriptionKey.Session(sessionId) + + private val windowStore = MutableStateFlow(null) + private val detailLoadFailed = MutableStateFlow(false) + + private var sseJob: Job? = null + private var initJob: Job? = null + private var seenJob: Job? = null + private var olderJob: Job? = null + private var draftJob: Job? = null + + // ------------------------------------------------------------ M3 state -- + + private val composerText = MutableStateFlow("") + private val sendInFlight = MutableStateFlow(false) + + /** + * Composer attachment tray (B-M3f). The screen feeds prepared picks in + * and renders `attachments.items`; [sendMessage] consumes the Ready set. + */ + val attachments = ComposerAttachments(api = api, sessionId = sessionId, scope = scope) + private val queuedOpPending = MutableStateFlow(false) + private val permissionOverrides = MutableStateFlow>(emptyMap()) + private val configOpPending = MutableStateFlow(false) + + private sealed interface CodexModels { + data object Idle : CodexModels + data object Loading : CodexModels + data class Loaded(val models: List) : CodexModels + data object Failed : CodexModels + } + + private val codexModels = MutableStateFlow(CodexModels.Idle) + + private sealed interface SlashFetch { + data object Idle : SlashFetch + data object Loading : SlashFetch + data class Loaded(val commands: List) : SlashFetch + data object Failed : SlashFetch + } + + private val slashFetch = MutableStateFlow(SlashFetch.Idle) + private val sessionOpPending = MutableStateFlow(false) + + private val _events = MutableSharedFlow(extraBufferCapacity = 16) + + /** One-shot effects: renavigation on supersede, snackbar notices. */ + val events: SharedFlow = _events.asSharedFlow() + + // Pipeline memo state — touched only inside the single uiState map stage. + private val normalizeCache = HashMap() + private var previousGroups: List = emptyList() + + private class NormalizeCacheEntry(val source: WindowMessage, val normalized: NormalizedMessage?) + + private data class PipelineInputs( + val window: MessageWindowState, + val detail: Session?, + val summary: SessionSummary?, + val machines: List, + val detailLoadFailed: Boolean, + val permissionOverrides: Map, + ) + + @OptIn(ExperimentalCoroutinesApi::class) + val uiState: StateFlow = windowStore + .filterNotNull() + .flatMapLatest { store -> + combine( + store.state, + sessionStore.sessionDetail(sessionId), + summaryFlow(), + machineStore.machines, + detailLoadFailed, + permissionOverrides, + ) { values: Array -> pipelineInputs(values) } + } + // The web samples pipeline runs through React batching; here: emit the + // first value immediately, then at most one (latest) run per interval. + .conflate() + .transform { inputs -> + emit(inputs) + delay(pipelineIntervalMs) + } + .map(::buildUiState) + .flowOn(pipelineDispatcher) + .stateIn(scope, SharingStarted.Eagerly, initialState()) + + /** Composer bar state (text is VM-owned so drafts and edit-prefill flow through it). */ + val composer: StateFlow = combine( + composerText, + sendInFlight, + sessionStateFlow(), + ) { text, sending, session -> + ComposerUiState( + text = text, + isSending = sending, + canSteer = session.thinking && session.active, + ) + }.stateIn(scope, SharingStarted.Eagerly, ComposerUiState(text = "", isSending = false, canSteer = false)) + + /** Uninvoked sends for the queued bar, ordered like the web (immediate first, then scheduled). */ + @OptIn(ExperimentalCoroutinesApi::class) + val queuedRows: StateFlow> = windowStore + .filterNotNull() + .flatMapLatest { store -> + combine(store.state, queuedOpPending, sessionStateFlow()) { window, opPending, session -> + buildQueuedRows(window, opPending, session.thinking) + } + } + .stateIn(scope, SharingStarted.Eagerly, emptyList()) + + /** Session config sheet model. */ + val config: StateFlow = combine( + sessionStore.sessionDetail(sessionId), + summaryFlow(), + codexModels, + configOpPending, + ) { detail, summary, models, _ -> + buildConfigUi(detail, summary, models) + }.stateIn(scope, SharingStarted.Eagerly, buildConfigUi(null, null, CodexModels.Idle)) + + /** + * Slash-command dropdown rows (B-M3ce): non-empty only while the composer + * text is a lone `/token`. Sources: the session's `metadata.slashCommands` + * names merged with the `GET /slash-commands` RPC list (fetched lazily on + * the first `/`), RPC entries winning dedupe. + */ + val slashSuggestions: StateFlow> = combine( + composerText, + slashFetch, + sessionStore.sessionDetail(sessionId), + ) { text, fetch, detail -> + val query = SlashCommands.queryOf(text) ?: return@combine emptyList() + val fetched = (fetch as? SlashFetch.Loaded)?.commands + SlashCommands.filter(SlashCommands.merge(detail?.metadata?.slashCommands, fetched), query) + }.stateIn(scope, SharingStarted.Eagerly, emptyList()) + + /** + * Entry count for the top-bar scratchlist badge (B-M4d); stays 0 without + * a wired store. The store refetches on [start] (open) and on the + * `scratchlistUpdatedAt` SSE trigger. + */ + val scratchlistCount: StateFlow = + (scratchlist?.state(sessionId)?.map { it.entries.size } ?: flowOf(0)) + .stateIn(scope, SharingStarted.Eagerly, 0) + + /** Whether a scratchlist store is wired — gates the badge and park affordances. */ + val scratchlistEnabled: Boolean = scratchlist != null + + // ------------------------------------------------------------ lifecycle -- + + /** Idempotent; call from the screen's composition, paired with [stop]. */ + fun start() { + if (initJob?.isActive == true || sseJob?.isActive == true) return + + initJob = scope.launch { + val store = messageWindows.open(sessionId) + store.activate() + windowStore.value = store + + // Subscribe only after the window exists: every routed message + // event / gap resync then finds a peekable window, and the + // collector registers before `subscribe` because the engine's + // SharedFlow has zero replay. + sseJob = scope.launch { + sseEngine.events(subscriptionKey) + .onSubscription { sseEngine.subscribe(subscriptionKey) } + .collect { router.route(subscriptionKey, it) } + } + + launch { + runCatching { store.syncTail() } + // Now that sends exist, verify optimistic queued rows against + // the hub on every chat open (web queued-state reconciliation). + runCatching { store.reconcileQueuedState() } + } + launch { restoreDraft() } + loadDetail() + } + + // Badge count + SSE-triggered refetches while this chat is on screen. + scratchlist?.open(sessionId) + + seenJob = scope.launch { + // Watermark = updatedAt currently on screen, from whichever cache + // is fresher (summary via global events, detail via this pipe). + merge( + sessionStore.sessions + .map { list -> list.firstOrNull { it.id == sessionId }?.updatedAt }, + sessionStore.sessionDetail(sessionId).map { it?.updatedAt }, + ) + .filterNotNull() + .distinctUntilChanged() + .collect { updatedAt -> lastSeenStore.markSeen(sessionId, updatedAt) } + } + } + + /** Tears the session pipe down (engine keeps the resume cursor). */ + fun stop() { + sseJob?.cancel() + sseJob = null + initJob?.cancel() + seenJob?.cancel() + olderJob?.cancel() + flushPendingDraft() + sseEngine.unsubscribe(subscriptionKey) + sessionStore.releaseDetail(sessionId) + scratchlist?.release(sessionId) + } + + /** + * A debounced draft save cancelled by screen exit would lose the last + * keystrokes; flush it on a detached scope — [scope] is torn down right + * after [stop] returns (the web analogue is the beforeunload persist). + */ + @OptIn(kotlinx.coroutines.DelicateCoroutinesApi::class) + private fun flushPendingDraft() { + val pending = draftJob?.isActive == true + draftJob?.cancel() + if (!pending) return + val store = drafts ?: return + val text = composerText.value + kotlinx.coroutines.GlobalScope.launch(Dispatchers.IO) { + runCatching { store.save(sessionId, text) } + } + } + + /** Initial-load error state → try again (detail + tail). */ + fun retry() { + scope.launch { + loadDetail() + windowStore.value?.let { store -> runCatching { store.syncTail(ensureAfterCurrent = true) } } + } + } + + /** Top-edge reached: one older page (no-ops while one is in flight). */ + fun loadOlder() { + val store = windowStore.value ?: return + if (olderJob?.isActive == true) return + olderJob = scope.launch { + runCatching { store.fetchOlder() } + } + } + + private suspend fun loadDetail() { + try { + sessionStore.loadSessionDetail(sessionId) + detailLoadFailed.value = false + } catch (cancellation: CancellationException) { + throw cancellation + } catch (_: Exception) { + detailLoadFailed.value = true + } + } + + // ------------------------------------------------------------- composer -- + + fun setComposerText(text: String) { + // Fetch the RPC command list on the transition INTO slash mode (the + // web refetches when the menu opens) — not on every keystroke, so a + // wedged CLI cannot be hammered while the user types a command. + val enteredSlashMode = SlashCommands.queryOf(text) != null && + SlashCommands.queryOf(composerText.value) == null + composerText.value = text + if (enteredSlashMode) loadSlashCommands() + draftJob?.cancel() + val store = drafts ?: return + draftJob = scope.launch { + delay(draftSaveDebounceMs) + runCatching { store.save(sessionId, text) } + } + } + + /** Dictation transcript arrived: append with a space separator (web `appendTranscript`). */ + fun appendDictatedText(transcript: String) { + setComposerText(appendTranscript(composerText.value, transcript)) + } + + /** + * Scratchlist "Send to composer" (B-M4d): insert [text] into the composer + * — an empty composer takes it verbatim, an existing draft keeps its + * words and the entry lands on a new line (the entry itself stays on the + * scratchlist, like the web's promote-to-composer). + */ + fun insertComposerText(text: String) { + if (text.isBlank()) return + val current = composerText.value + setComposerText(if (current.isBlank()) text else "${current.trimEnd()}\n$text") + } + + /** + * Scratchlist "Park from composer" (B-M4d): the current draft becomes a + * scratchlist entry and the composer clears (store-optimistic; the + * composer clears only after the hub accepts, so a failed park cannot + * lose the draft). + */ + fun parkComposerDraft() { + val store = scratchlist ?: return + val text = composerText.value + if (text.isBlank()) return + scope.launch { + when (val result = store.createEntry(sessionId, text)) { + is ScratchlistCreateResult.Created -> { + // Clear only when the draft is still what we parked (the + // operator may have kept typing while the POST ran). + if (composerText.value == text) setComposerText("") + _events.tryEmit(ChatEvent.Notice(ChatNotice.DraftParked)) + } + ScratchlistCreateResult.AtCap -> + _events.tryEmit(ChatEvent.Notice(ChatNotice.ScratchlistFull)) + is ScratchlistCreateResult.Failed -> + _events.tryEmit(ChatEvent.Notice(ChatNotice.ScratchlistParkFailed)) + } + } + } + + /** Dropdown tap: replace the slash token with `/name ` ready for arguments. */ + fun selectSlashCommand(command: SlashCommand) { + setComposerText("/${command.name} ") + } + + /** + * `GET /slash-commands` once per screen (near-static list; a failed fetch + * retries on the next `/`). RPC failure is silent — the metadata names + * still populate the menu, like the web's builtin fallback. + */ + private fun loadSlashCommands() { + if (slashFetch.value is SlashFetch.Loading || slashFetch.value is SlashFetch.Loaded) return + slashFetch.value = SlashFetch.Loading + scope.launch { + slashFetch.value = try { + val response = api.getSlashCommands(sessionId) + val commands = response.commands + if (response.success && commands != null) SlashFetch.Loaded(commands) else SlashFetch.Failed + } catch (cancellation: CancellationException) { + throw cancellation + } catch (_: Exception) { + SlashFetch.Failed + } + } + } + + /** + * Submit the composer. Delivery defaults to durable queue; [steer] is the + * explicit long-press intent that delivers into the active turn + * (`deliveryMode: "steer"` — `messageDelivery.ts` semantics; attachments + * may ride a steer, only `scheduledAt` excludes them). + * + * Ready attachments are consumed into `SendMessageRequest.attachments`; + * an unsettled chip (uploading/failed) blocks the send with a notice. + * Text may be empty when attachments exist (wire: text OR attachments). + */ + fun sendMessage(steer: Boolean = false) { + if (sendInFlight.value) return + if (attachments.hasUnsettled()) { + _events.tryEmit(ChatEvent.Notice(ChatNotice.AttachmentsUploading)) + return + } + val text = composerText.value.trim() + val attachmentMetadata = attachments.consume() + if (text.isEmpty() && attachmentMetadata == null) return + composerText.value = "" + draftJob?.cancel() + scope.launch { + drafts?.let { runCatching { it.clear(sessionId) } } + performSend( + text = text, + localId = localIdGenerator(), + createdAt = now(), + deliveryMode = if (steer) "steer" else "queue", + attachments = attachmentMetadata, + isRetry = false, + ) + } + } + + /** + * The screen is going away for good (holder `onCleared`, not a config + * change): un-sent uploads are discarded after a best-effort hub delete. + * Attachments deliberately do not persist in drafts v1. + */ + fun discardAttachments() { + attachments.discardAllDetached() + } + + /** Tap-to-retry on a failed optimistic row: re-fires the send with the same localId. */ + fun retryFailedMessage(localId: String) { + if (sendInFlight.value) return + scope.launch { + val store = awaitWindowStore() + val row = store.state.value.messages + .firstOrNull { it.localId == localId && it.status == MessageStatus.Failed } + ?: return@launch + val payload = sendPayloadOf(row) ?: return@launch + performSend( + text = payload.text, + localId = localId, + createdAt = row.createdAt, + // A retry cannot prove the original turn is still live — + // steer degrades to queue (web `getRetryDeliveryMode`). + deliveryMode = "queue", + attachments = payload.attachments, + scheduledAt = row.wire.scheduledAt, + isRetry = true, + ) + } + } + + private class SendPayload(val text: String, val attachments: List?) + + /** Extract text + attachments from an optimistic user row's wire content. */ + private fun sendPayloadOf(row: WindowMessage): SendPayload? { + val inner = row.wire.content.objOrNull?.get("content").objOrNull ?: return null + val text = inner["text"].stringOrNull ?: return null + val attachments = inner["attachments"].arrayOrNull?.let { array -> + runCatching { + HapiJson.decodeFromJsonElement(ListSerializer(AttachmentMetadata.serializer()), array) + }.getOrNull() + }?.takeIf { it.isNotEmpty() } + return SendPayload(text, attachments) + } + + private suspend fun performSend( + text: String, + localId: String, + createdAt: Long, + deliveryMode: String, + attachments: List? = null, + scheduledAt: Long? = null, + isRetry: Boolean, + ) { + // Wire constraint (SendMessageRequestSchema): scheduled sends exclude + // attachments (and steer). No Android surface can produce the combo + // today — this trips loudly if a scheduled-send UI ever forgets it. + check(scheduledAt == null || attachments.isNullOrEmpty()) { + "scheduled sends cannot carry attachments" + } + sendInFlight.value = true + try { + val store = awaitWindowStore() + if (isRetry) { + store.updateStatus(localId, MessageStatus.Sending) + } else { + store.appendOptimistic( + localId = localId, + text = text, + attachments = attachments, + scheduledAt = scheduledAt, + deliveryMode = deliveryMode, + createdAt = createdAt, + ) + } + val request = SendMessageRequest( + text = text, + localId = localId, + attachments = attachments, + scheduledAt = scheduledAt, + deliveryMode = deliveryMode, + ) + try { + api.sendMessage(sessionId, request) + store.updateStatus(localId, successStatus()) + } catch (cancellation: CancellationException) { + throw cancellation + } catch (error: Exception) { + if (error.isSessionInactive()) { + resumeAndRetry(store, request, localId) + } else { + store.updateStatus(localId, MessageStatus.Failed) + } + } + } finally { + sendInFlight.value = false + } + } + + /** Queued while a turn is active, sent otherwise (web `onMutate` successStatus). */ + private fun successStatus(): MessageStatus = + if (currentSessionState().thinking) MessageStatus.Queued else MessageStatus.Sent + + /** + * `session_inactive` recovery (web `resolveSessionId` semantics, + * `router.tsx`): one `POST /resume`, then retry the send against the id + * the hub returns. A different id supersedes this session — seed the new + * window from this one, migrate the draft, retarget the optimistic row, + * and tell the screen to renavigate. + */ + private suspend fun resumeAndRetry( + store: MessageWindowStore, + request: SendMessageRequest, + localId: String, + ) { + val targetSessionId = try { + api.resumeSession(sessionId, currentDetail()?.permissionMode).sessionId + } catch (cancellation: CancellationException) { + throw cancellation + } catch (error: Exception) { + store.updateStatus(localId, MessageStatus.Failed) + _events.tryEmit(ChatEvent.Notice(ChatNotice.ResumeFailed)) + return + } + + val optimisticRow = store.state.value.messages.firstOrNull { it.localId == localId } + var targetStore = store + if (targetSessionId != sessionId) { + messageWindows.seed(sessionId, targetSessionId) + targetStore = messageWindows.open(targetSessionId) + if (optimisticRow != null) { + // Seeding copies rows across, but make the hand-off explicit: + // the pending row must live in the target window only. + targetStore.appendOptimistic(optimisticRow) + store.removeMessage(localId) + } + drafts?.let { runCatching { it.move(sessionId, targetSessionId) } } + } + + // Resume succeeded: reflect activity locally, refresh the list row. + sessionStore.updateDetailLocal(sessionId) { it.copy(active = true) } + sessionStore.scheduleRefresh() + + try { + api.sendMessage(targetSessionId, request) + targetStore.updateStatus(localId, successStatus()) + } catch (cancellation: CancellationException) { + throw cancellation + } catch (error: Exception) { + targetStore.updateStatus(localId, MessageStatus.Failed) + } + if (targetSessionId != sessionId) { + _events.tryEmit(ChatEvent.SessionSuperseded(targetSessionId)) + } + } + + /** `POST /abort` — confirm-free stop of the active turn. */ + fun abortSession() { + scope.launch { + try { + api.abortSession(sessionId) + } catch (cancellation: CancellationException) { + throw cancellation + } catch (error: Exception) { + _events.tryEmit(ChatEvent.Notice(ChatNotice.AbortFailed(error.message))) + } + } + } + + // ---------------------------------------------------------- session ops -- + + /** `PATCH /sessions/:id` rename — optimistic name in the store, rolled forward on failure. */ + fun renameSession(name: String) { + val trimmed = name.trim() + if (trimmed.isEmpty()) return + scope.launch { + try { + sessionStore.renameSession(sessionId, trimmed) + } catch (cancellation: CancellationException) { + throw cancellation + } catch (error: Exception) { + _events.tryEmit(ChatEvent.Notice(ChatNotice.RenameFailed(error.message))) + } + } + } + + /** `DELETE /sessions/:id` — [ChatEvent.SessionDeleted] on success; 409 while active. */ + fun deleteSession() { + if (!sessionOpPending.compareAndSet(expect = false, update = true)) return + scope.launch { + try { + sessionStore.deleteSession(sessionId) + _events.tryEmit(ChatEvent.SessionDeleted) + } catch (cancellation: CancellationException) { + throw cancellation + } catch (error: Exception) { + val notice = if (error is ApiError && error.status == 409) { + ChatNotice.DeleteConflictActive + } else { + ChatNotice.DeleteFailed(error.message) + } + _events.tryEmit(ChatEvent.Notice(notice)) + } finally { + sessionOpPending.value = false + } + } + } + + /** + * `POST /reopen` for an inactive session. A superseding id gets the same + * treatment as the send-resume path: window seed + draft move + + * [ChatEvent.SessionSuperseded]. 422 (metadata incomplete) surfaces via + * [formatReopenError]. + */ + fun reopenSession() { + if (!sessionOpPending.compareAndSet(expect = false, update = true)) return + scope.launch { + try { + val response = sessionStore.reopenSession(sessionId) + if (response.sessionId != sessionId) { + messageWindows.seed(sessionId, response.sessionId) + drafts?.let { runCatching { it.move(sessionId, response.sessionId) } } + _events.tryEmit(ChatEvent.SessionSuperseded(response.sessionId)) + } + } catch (cancellation: CancellationException) { + throw cancellation + } catch (error: Exception) { + _events.tryEmit(ChatEvent.Notice(ChatNotice.ReopenFailed(formatReopenError(error)))) + } finally { + sessionOpPending.value = false + } + } + } + + // ----------------------------------------------------------- queued bar -- + + /** + * Cancel one queued message: optimistic removal, `DELETE`; an `invoked` + * answer means the agent already consumed it — ingest the authoritative + * row as sent (web `useCancelQueuedMessage`). Errors restore the row. + */ + fun cancelQueuedMessage(messageId: String) { + scope.launch { cancelQueuedInternal(messageId) } + } + + /** @return the cancel verdict: `"cancelled"`, `"invoked"`, or null on guard/error. */ + private suspend fun cancelQueuedInternal(messageId: String): String? { + val store = awaitWindowStore() + val row = store.state.value.messages.firstOrNull { it.id == messageId } ?: return null + if (!canActOnQueuedRow(row)) return null + if (!queuedOpPending.compareAndSet(expect = false, update = true)) return null + val localId = row.localId ?: row.id + store.removeMessage(localId) + return try { + val response = api.cancelMessage(sessionId, messageId) + val invokedMessage = response.message + if (response.status == "invoked" && invokedMessage != null) { + store.appendOptimistic(invokedMessage.asWindowMessage(MessageStatus.Sent)) + } else if (response.status == "busy") { + store.appendOptimistic(row.copy(status = MessageStatus.Indeterminate)) + runCatching { store.reconcileQueuedState() } + } + response.status + } catch (cancellation: CancellationException) { + throw cancellation + } catch (error: Exception) { + store.appendOptimistic(row) + _events.tryEmit(ChatEvent.Notice(ChatNotice.CancelQueuedFailed(error.message))) + null + } finally { + queuedOpPending.value = false + } + } + + fun retryIndeterminateMessage(messageId: String) { + if (!queuedOpPending.compareAndSet(expect = false, update = true)) return + scope.launch { + try { + val response = api.retryIndeterminateMessage(sessionId, messageId) + val message = response.message + if (response.status == "invoked" && message != null) { + val localId = message.localId + val invokedAt = message.invokedAtOrNull + if (localId != null && invokedAt != null) { + awaitWindowStore().markConsumed(listOf(localId), invokedAt) + } + } + if (response.status == "retried" || response.status == "already-queued") { + response.localId?.let { awaitWindowStore().markRequeued(listOf(it)) } + } else if (response.status == "not-found") { + awaitWindowStore().removeMessage(messageId) + _events.tryEmit(ChatEvent.Notice(ChatNotice.CancelQueuedFailed("Message is no longer available"))) + } else if (response.status == "retry-unavailable") { + _events.tryEmit(ChatEvent.Notice(ChatNotice.CancelQueuedFailed("Delivery is still being resolved"))) + } + } catch (error: Exception) { + _events.tryEmit(ChatEvent.Notice(ChatNotice.CancelQueuedFailed(error.message))) + } finally { + queuedOpPending.value = false + } + } + } + + /** Edit = cancel + prefill composer (kept when the operator typed meanwhile). */ + fun editQueuedMessage(messageId: String) { + scope.launch { + val store = awaitWindowStore() + val row = store.state.value.messages.firstOrNull { it.id == messageId } ?: return@launch + val preview = queuedPreview(row) + val editText = preview.text.ifEmpty { preview.attachmentNames.joinToString(", ") } + val composerAtEdit = composerText.value + when (cancelQueuedInternal(messageId)) { + "cancelled" -> { + if (composerText.value == composerAtEdit) { + setComposerText(editText) + } else { + _events.tryEmit(ChatEvent.Notice(ChatNotice.QueuedEditKeptDraft)) + } + } + "invoked" -> _events.tryEmit(ChatEvent.Notice(ChatNotice.QueuedAlreadyDelivered)) + else -> Unit + } + } + } + + /** + * Steer one queued message into the active turn. Non-optimistic: the + * `messages-consumed` event settles the row (web `useSteerQueuedMessage`); + * an `invoked` answer reconciles a missed consume. + */ + fun steerQueuedMessage(messageId: String) { + scope.launch { + val store = awaitWindowStore() + val row = store.state.value.messages.firstOrNull { it.id == messageId } ?: return@launch + if (!canActOnQueuedRow(row) || row.wire.scheduledAt != null) return@launch + if (!queuedOpPending.compareAndSet(expect = false, update = true)) return@launch + try { + val response = api.steerMessage(sessionId, messageId) + when (response.status) { + "failed" -> _events.tryEmit( + ChatEvent.Notice(ChatNotice.SteerFailed(response.error)), + ) + "invoked" -> { + val message = response.message + val invokedLocalId = message?.localId + val invokedAt = message?.invokedAtOrNull + if (invokedLocalId != null && invokedAt != null) { + store.markConsumed(listOf(invokedLocalId), invokedAt) + } + } + else -> Unit // "steered": messages-consumed removes the row. + } + } catch (cancellation: CancellationException) { + throw cancellation + } catch (error: Exception) { + _events.tryEmit(ChatEvent.Notice(ChatNotice.SteerFailed(error.message))) + } finally { + queuedOpPending.value = false + } + } + } + + private fun canActOnQueuedRow(row: WindowMessage): Boolean { + val hasServerEcho = row.localId == null || row.id != row.localId + return hasServerEcho && !queuedOpPending.value + } + + private class QueuedPreview(val text: String, val attachmentNames: List) + + private fun queuedPreview(row: WindowMessage): QueuedPreview { + val normalized = normalizeDecryptedMessage(row.wire) as? NormalizedMessage.User + ?: return QueuedPreview("", emptyList()) + return QueuedPreview( + text = normalized.text.trim(), + attachmentNames = normalized.attachments?.map { it.filename } ?: emptyList(), + ) + } + + private fun buildQueuedRows( + window: MessageWindowState, + opPending: Boolean, + thinking: Boolean, + ): List { + val queued = window.messages.filter { it.isQueuedForInvocation } + // Web `sortQueuedMessages`: immediate first (submission order), then + // scheduled by fire time. + val sorted = queued.sortedWith( + compareBy { it.wire.scheduledAt != null } + .thenBy { it.wire.scheduledAt ?: it.createdAt }, + ) + return sorted.map { row -> + val preview = queuedPreview(row) + val hasServerEcho = row.localId == null || row.id != row.localId + val canAct = hasServerEcho && !opPending + QueuedRowUi( + id = row.id, + localId = row.localId, + text = preview.text, + attachmentNames = preview.attachmentNames, + scheduledAt = row.wire.scheduledAt, + canAct = canAct, + canSteer = canAct && thinking && row.wire.scheduledAt == null + && row.status != MessageStatus.Indeterminate, + indeterminate = row.status == MessageStatus.Indeterminate, + ) + } + } + + // ---------------------------------------------------------- permissions -- + + /** + * Apply one permission decision. Wire bodies match the web + * `PermissionFooter`/`AskUserQuestionFooter`/`RequestUserInputFooter` + * exactly; 404/409 from the hub mean the request already settled + * elsewhere — surfaced as a benign [PermissionRowOverride.AlreadyHandled]. + */ + fun resolvePermission(requestId: String, action: PermissionAction) { + if (permissionOverrides.value.containsKey(requestId)) return + permissionOverrides.update { it + (requestId to PermissionRowOverride.Resolving) } + scope.launch { + try { + when (action) { + PermissionAction.Deny -> api.denyPermission(sessionId, requestId) + PermissionAction.Abort -> api.denyPermission(sessionId, requestId, decision = "abort") + else -> api.approvePermission(sessionId, requestId, approveBody(requestId, action)) + } + // Success: stay `Resolving`; the agentState patch clears the + // pending request and the pipeline prunes the override. + } catch (cancellation: CancellationException) { + throw cancellation + } catch (error: Exception) { + if (error is ApiError && (error.status == 404 || error.status == 409)) { + permissionOverrides.update { it + (requestId to PermissionRowOverride.AlreadyHandled) } + _events.tryEmit(ChatEvent.Notice(ChatNotice.PermissionAlreadyHandled)) + } else { + permissionOverrides.update { it - requestId } + _events.tryEmit(ChatEvent.Notice(ChatNotice.PermissionRequestFailed(error.message))) + } + } + } + } + + private fun approveBody(requestId: String, action: PermissionAction): ApprovePermissionRequest { + val flavor = currentFlavor() + val request = currentDetail()?.agentState?.requests?.get(requestId) + val toolName = request?.tool + val codexUx = isCodexPermissionUx(flavor, toolName) + return when (action) { + PermissionAction.Allow -> + if (codexUx) ApprovePermissionRequest(decision = "approved") + else ApprovePermissionRequest() + + PermissionAction.AllowForSession -> + if (flavor == "claude") { + val command = if (toolName == "Bash") { + getInputStringAny(request?.arguments, listOf("command", "cmd")) + } else { + null + } + val toolIdentifier = if (toolName == "Bash" && command != null) { + "Bash($command)" + } else { + toolName ?: "" + } + ApprovePermissionRequest(allowTools = listOf(toolIdentifier)) + } else { + ApprovePermissionRequest(decision = "approved_for_session") + } + + PermissionAction.AllowAllEdits -> ApprovePermissionRequest(mode = "acceptEdits") + + is PermissionAction.FlatAnswers -> ApprovePermissionRequest( + answers = buildJsonObject { + action.answers.forEach { (key, values) -> + put(key, JsonArray(values.map(::JsonPrimitive))) + } + }, + ) + + is PermissionAction.NestedAnswers -> ApprovePermissionRequest( + answers = buildJsonObject { + action.answers.forEach { (key, values) -> + put( + key, + buildJsonObject { + put("answers", buildJsonArray { values.forEach { add(JsonPrimitive(it)) } }) + }, + ) + } + }, + ) + + PermissionAction.Deny, PermissionAction.Abort -> + error("deny actions do not build approve bodies") + } + } + + // ---------------------------------------------------------------- config -- + + /** `POST /permission-mode` with an optimistic detail flip; server truth on error. */ + fun setPermissionMode(mode: PermissionMode) { + runConfigChange( + optimistic = { it.copy(permissionMode = mode.wireId) }, + call = { api.setPermissionMode(sessionId, mode.wireId) }, + ) + } + + /** `POST /model` — null clears back to the agent default. */ + fun setModel(model: String?) { + runConfigChange( + optimistic = { it.copy(model = model) }, + call = { api.setModel(sessionId, model) }, + ) + } + + /** + * Effort switch, flavor-routed: claude → `POST /effort`; codex/opencode → + * `POST /model-reasoning-effort`. Null clears. + */ + fun setEffort(effort: String?) { + val usesReasoningEffort = currentFlavor() == "codex" || currentFlavor() == "opencode" + runConfigChange( + optimistic = { + if (usesReasoningEffort) it.copy(modelReasoningEffort = effort) else it.copy(effort = effort) + }, + call = { + if (usesReasoningEffort) { + api.setModelReasoningEffort(sessionId, effort) + } else { + api.setEffort(sessionId, effort) + } + }, + ) + } + + /** Fetch the codex model catalog for the picker (no-op for other flavors). */ + fun loadModelOptions() { + if (currentFlavor() != "codex") return + if (codexModels.value is CodexModels.Loading || codexModels.value is CodexModels.Loaded) return + codexModels.value = CodexModels.Loading + scope.launch { + codexModels.value = try { + val response = api.getSessionCodexModels(sessionId) + val models = response.models + if (response.success && models != null) { + CodexModels.Loaded(models) + } else { + _events.tryEmit(ChatEvent.Notice(ChatNotice.ModelsLoadFailed(response.error))) + CodexModels.Failed + } + } catch (cancellation: CancellationException) { + throw cancellation + } catch (error: Exception) { + _events.tryEmit(ChatEvent.Notice(ChatNotice.ModelsLoadFailed(error.message))) + CodexModels.Failed + } + } + } + + private fun runConfigChange(optimistic: (Session) -> Session, call: suspend () -> Unit) { + if (!configOpPending.compareAndSet(expect = false, update = true)) return + sessionStore.updateDetailLocal(sessionId, optimistic) + scope.launch { + try { + call() + } catch (cancellation: CancellationException) { + throw cancellation + } catch (error: Exception) { + // Roll back by rolling forward to server truth (an SSE patch + // may have moved other fields since the optimistic write). + runCatching { sessionStore.loadSessionDetail(sessionId) } + _events.tryEmit(ChatEvent.Notice(ChatNotice.ConfigUpdateFailed(error.message))) + } finally { + configOpPending.value = false + } + } + } + + private fun buildConfigUi(detail: Session?, summary: SessionSummary?, models: CodexModels): SessionConfigUi { + val flavor = detail?.metadata?.flavor ?: summary?.metadata?.flavor + val model = detail?.model + val modelOptions: List? + var modelOptionsLoading = false + var effort: String? = null + var effortOptions: List? = null + + when (flavor) { + "claude" -> { + modelOptions = ModelCatalog.claudeModelOptions(model) + effort = detail?.effort + effortOptions = ModelCatalog.claudeEffortOptions(effort) + } + "codex" -> { + when (models) { + is CodexModels.Loaded -> { + modelOptions = models.models.map { summaryRow -> + CatalogOption( + value = summaryRow.id, + label = summaryRow.displayName + if (summaryRow.isDefault) " · default" else "", + ) + } + val selected = models.models.firstOrNull { it.id == model } + ?: models.models.firstOrNull { it.isDefault } + val efforts = selected?.supportedReasoningEfforts.orEmpty() + if (efforts.isNotEmpty()) { + effort = detail?.modelReasoningEffort + effortOptions = listOf(CatalogOption(null, "Default")) + efforts.map { level -> + CatalogOption(level, level.replaceFirstChar { it.uppercaseChar() }) + } + } + } + is CodexModels.Loading -> { + modelOptions = emptyList() + modelOptionsLoading = true + } + else -> modelOptions = emptyList() + } + } + else -> modelOptions = null // Generic fallback: hide the picker. + } + + return SessionConfigUi( + flavor = flavor, + active = detail?.active ?: summary?.active ?: false, + controlledByUser = detail?.agentState?.controlledByUser == true, + permissionMode = detail?.permissionMode, + permissionModes = PermissionModes.forFlavor(flavor), + model = model, + modelOptions = modelOptions, + modelOptionsLoading = modelOptionsLoading, + effort = effort, + effortOptions = effortOptions, + ) + } + + // ------------------------------------------------------------- internals -- + + private suspend fun awaitWindowStore(): MessageWindowStore = + windowStore.filterNotNull().first() + + private fun currentDetail(): Session? = + sessionStore.currentDetail(sessionId) + + private fun currentFlavor(): String? = + currentDetail()?.metadata?.flavor + ?: sessionStore.sessions.value.firstOrNull { it.id == sessionId }?.metadata?.flavor + + private class SessionLiveState(val active: Boolean, val thinking: Boolean) + + private fun currentSessionState(): SessionLiveState { + val detail = currentDetail() + if (detail != null) return SessionLiveState(detail.active, detail.thinking) + val summary = sessionStore.sessions.value.firstOrNull { it.id == sessionId } + return SessionLiveState(summary?.active ?: false, summary?.thinking ?: false) + } + + private fun sessionStateFlow() = combine( + sessionStore.sessionDetail(sessionId), + summaryFlow(), + ) { detail, summary -> + SessionLiveState( + active = detail?.active ?: summary?.active ?: false, + thinking = detail?.thinking ?: summary?.thinking ?: false, + ) + } + + private fun summaryFlow() = sessionStore.sessions + .map { list -> list.firstOrNull { it.id == sessionId } } + .distinctUntilChanged() + + private suspend fun restoreDraft() { + val store = drafts ?: return + val draft = runCatching { store.load(sessionId) }.getOrNull() ?: return + if (composerText.value.isEmpty()) { + composerText.value = draft + } + } + + private fun pipelineInputs(values: Array): PipelineInputs { + @Suppress("UNCHECKED_CAST") + return PipelineInputs( + window = values[0] as MessageWindowState, + detail = values[1] as Session?, + summary = values[2] as SessionSummary?, + machines = values[3] as List, + detailLoadFailed = values[4] as Boolean, + permissionOverrides = values[5] as Map, + ) + } + + // ------------------------------------------------------------- pipeline -- + + private fun initialState() = ChatUiState( + sessionId = sessionId, + header = ChatHeaderUi(title = sessionId.take(8), subtitle = null, active = false, thinking = false), + flavor = null, + basePath = null, + blocks = emptyList(), + permissionOverrides = emptyMap(), + hasMore = false, + isLoadingOlder = false, + isSyncingTail = true, + isInitialLoading = true, + loadFailed = false, + warning = null, + tailRevision = 0, + ) + + private fun buildUiState(inputs: PipelineInputs): ChatUiState { + val window = inputs.window + + // Queued-not-yet-invoked rows belong to the composer bar, not the + // thread — shared predicate with the window store, like the web. + val visibleMessages = window.messages.filter { !it.isQueuedForInvocation } + + val normalized = ArrayList(visibleMessages.size) + val seen = HashSet(visibleMessages.size * 2) + for (message in visibleMessages) { + if (!seen.add(message.id)) continue + val cached = normalizeCache[message.id] + if (cached != null && cached.source === message) { + cached.normalized?.let(normalized::add) + continue + } + // Re-attach the window row's client-side status after normalizing + // the bare wire (web parity: `normalize.ts` copies `message.status` + // onto the normalized row). Without this, failed sends never render + // as failed and tap-to-retry can't trigger. Memo-safe: status + // changes always allocate a new row instance (B-M2c contract). + val bare = normalizeDecryptedMessage(message.wire) + val rowStatus = message.status + val next = if (bare is NormalizedMessage.User && rowStatus != null) { + bare.copy(status = rowStatus.wire) + } else { + bare + } + normalizeCache[message.id] = NormalizeCacheEntry(message, next) + next?.let(normalized::add) + } + normalizeCache.keys.retainAll(seen) + + val agentState = inputs.detail?.agentState + val reduced = reduceChatBlocks(normalized, agentState) + val visibleBlocks = buildVisibleChatBlocks( + reduced.blocks, + ToolGroupingOptions(hasMoreMessages = window.hasMore, previousGroups = previousGroups), + ) + previousGroups = visibleBlocks.filterIsInstance() + + prunePermissionOverrides(agentState, inputs.permissionOverrides) + + val isEmpty = visibleBlocks.isEmpty() + // syncGeneration 0 = no tail sync has even begun (the moment between + // open and syncTail) — still "loading", never a flash of empty state. + val syncSettled = !window.isSyncingTail && window.syncGeneration > 0 + return ChatUiState( + sessionId = sessionId, + header = buildHeader(inputs), + flavor = inputs.detail?.metadata?.flavor ?: inputs.summary?.metadata?.flavor, + basePath = inputs.detail?.metadata?.path ?: inputs.summary?.metadata?.path, + blocks = visibleBlocks, + permissionOverrides = inputs.permissionOverrides, + hasMore = window.hasMore, + isLoadingOlder = window.isLoadingMore, + isSyncingTail = window.isSyncingTail, + isInitialLoading = isEmpty && !syncSettled && window.warning == null, + loadFailed = isEmpty && syncSettled && + (window.warning != null || inputs.detailLoadFailed), + warning = window.warning, + tailRevision = window.tailRevision, + ) + } + + /** A settled request (gone from `agentState.requests`) drops its override. */ + private fun prunePermissionOverrides( + agentState: AgentState?, + overrides: Map, + ) { + if (overrides.isEmpty()) return + // A missing agentState means the detail is (re)loading, not that the + // requests settled — never prune on absence of evidence. + if (agentState == null) return + val pendingIds = agentState.requests?.keys ?: emptySet() + val stale = overrides.keys.filter { it !in pendingIds } + if (stale.isEmpty()) return + permissionOverrides.update { current -> current - stale.toSet() } + } + + private fun buildHeader(inputs: PipelineInputs): ChatHeaderUi { + val detail = inputs.detail + val summary = inputs.summary + + // Detail first — the fresher source once loaded (this pipe patches it + // live); a detail without usable metadata falls through to the list + // summary, then to the id prefix (`getSessionTitle` cascade). + val title = detail?.let(::detailTitle) + ?: summary?.let(SessionListViewModel::sessionTitle) + ?: sessionId.take(8) + + val flavor = detail?.metadata?.flavor ?: summary?.metadata?.flavor + val machineId = detail?.metadata?.machineId ?: summary?.metadata?.machineId + val machineLabel = machineId?.let { id -> + val metadata = inputs.machines.firstOrNull { it.id == id }?.metadata + metadata?.displayName?.takeIf { it.isNotBlank() } ?: metadata?.host ?: id.take(8) + } + val worktree = (detail?.metadata?.worktree ?: summary?.metadata?.worktree) + ?.let { it.name.ifBlank { it.branch } } + val subtitle = listOfNotNull(flavor?.let(Flavors::label), machineLabel, worktree) + .takeIf { it.isNotEmpty() } + ?.joinToString(" · ") + + return ChatHeaderUi( + title = title, + subtitle = subtitle, + flavor = flavor, + name = detail?.metadata?.name ?: summary?.metadata?.name, + active = detail?.active ?: summary?.active ?: false, + thinking = detail?.thinking ?: summary?.thinking ?: false, + ) + } + + /** Detail title cascade; null when the metadata carries nothing usable. */ + private fun detailTitle(detail: Session): String? { + val metadata = detail.metadata ?: return null + metadata.name?.takeIf { it.isNotEmpty() }?.let { return it } + metadata.summary?.text?.takeIf { it.isNotEmpty() }?.let { return it } + return metadata.path.split('/').lastOrNull { it.isNotEmpty() } + } + + private companion object { + /** Web-equivalent render batching for the pipeline (the "sample(100ms)"). */ + const val PIPELINE_INTERVAL_MS: Long = 100 + + const val DRAFT_SAVE_DEBOUNCE_MS: Long = 300 + + fun Exception.isSessionInactive(): Boolean = + this is ApiError && status == 409 && code == "session_inactive" + + /** + * Codex-style approval UX (`PermissionFooter.isCodexSession`): codex + * family or cursor flavor, or a codex-dialect tool name. + */ + fun isCodexPermissionUx(flavor: String?, toolName: String?): Boolean = + Flavors.isCodexFamily(flavor) || + flavor == "cursor" || + toolName?.let { name -> + name.startsWith("Codex") || name.startsWith("Gemini") || + name.startsWith("OpenCode") || name.startsWith("Copilot") || + name.startsWith("Cursor") + } == true + } +} + diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/chat/SessionConfigSheet.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/SessionConfigSheet.kt new file mode 100644 index 0000000000..73885f3944 --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/SessionConfigSheet.kt @@ -0,0 +1,258 @@ +package app.hapi.companion.feature.chat + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import app.hapi.companion.R +import app.hapi.companion.ui.theme.HapiTheme +import app.hapi.companion.ui.theme.hapi +import app.hapi.protocol.catalog.CatalogOption +import app.hapi.protocol.catalog.ModelCatalog +import app.hapi.protocol.catalog.PermissionMode +import app.hapi.protocol.catalog.PermissionModeTone +import app.hapi.protocol.catalog.PermissionModes + +/** + * Session config sheet (B-M3b): permission mode / model / effort sections, + * catalog-driven per flavor. Pickers apply optimistically through the + * ViewModel ([ChatViewModel.setPermissionMode] & co.); a flavor without a + * known model catalog simply hides that section. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SessionConfigSheet( + config: SessionConfigUi, + onDismiss: () -> Unit, + onSetPermissionMode: (PermissionMode) -> Unit, + onSetModel: (String?) -> Unit, + onSetEffort: (String?) -> Unit, + onLoadModelOptions: () -> Unit, +) { + LaunchedEffect(Unit) { onLoadModelOptions() } + + ModalBottomSheet(onDismissRequest = onDismiss) { + SessionConfigSheetContent( + config = config, + onSetPermissionMode = onSetPermissionMode, + onSetModel = onSetModel, + onSetEffort = onSetEffort, + ) + } +} + +@Composable +internal fun SessionConfigSheetContent( + config: SessionConfigUi, + onSetPermissionMode: (PermissionMode) -> Unit, + onSetModel: (String?) -> Unit, + onSetEffort: (String?) -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp) + .padding(bottom = 24.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + if (!config.active) { + Notice(stringResource(R.string.chat_config_offline_note)) + } else if (config.controlledByUser) { + Notice(stringResource(R.string.chat_config_terminal_note)) + } + + if (config.permissionModes.isNotEmpty()) { + SectionTitle(stringResource(R.string.chat_config_permission_mode)) + config.permissionModes.forEach { mode -> + OptionRow( + label = mode.label, + selected = mode.wireId == (config.permissionMode ?: "default"), + tone = mode.tone, + onClick = { onSetPermissionMode(mode) }, + ) + } + } + + val modelOptions = config.modelOptions + if (modelOptions != null || config.modelOptionsLoading) { + SectionTitle(stringResource(R.string.chat_config_model)) + if (config.modelOptionsLoading) { + Row( + modifier = Modifier.padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) + Text( + text = stringResource(R.string.chat_config_loading_models), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.hapi.hint, + modifier = Modifier.padding(start = 8.dp), + ) + } + } else if (modelOptions.isNullOrEmpty()) { + Text( + text = stringResource(R.string.chat_config_models_unavailable), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.hapi.hint, + ) + } else { + val currentModel = normalizedCurrentModel(config) + modelOptions.forEach { option -> + OptionRow( + label = option.label, + selected = option.value == currentModel, + onClick = { onSetModel(option.value) }, + ) + } + } + } + + config.effortOptions?.let { effortOptions -> + SectionTitle(stringResource(R.string.chat_config_effort)) + val currentEffort = normalizedCurrentEffort(config) + effortOptions.forEach { option -> + OptionRow( + label = option.label, + selected = option.value == currentEffort, + onClick = { onSetEffort(option.value) }, + ) + } + } + } +} + +/** Claude models normalize `auto`/`default` to the null Default row. */ +private fun normalizedCurrentModel(config: SessionConfigUi): String? = + if (config.flavor == "claude") ModelCatalog.normalizeClaudeModel(config.model) else config.model + +private fun normalizedCurrentEffort(config: SessionConfigUi): String? = + if (config.flavor == "claude") ModelCatalog.normalizeClaudeEffort(config.effort) else config.effort + +@Composable +private fun SectionTitle(text: String) { + Text( + text = text, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(top = 10.dp, bottom = 2.dp), + ) +} + +@Composable +private fun OptionRow( + label: String, + selected: Boolean, + onClick: () -> Unit, + tone: PermissionModeTone? = null, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .selectable(selected = selected, onClick = onClick) + .padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton(selected = selected, onClick = null) + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + color = when (tone) { + PermissionModeTone.Danger -> MaterialTheme.colorScheme.error + PermissionModeTone.Warning -> MaterialTheme.colorScheme.tertiary + else -> MaterialTheme.colorScheme.onSurface + }, + modifier = Modifier.padding(start = 8.dp), + ) + } +} + +@Composable +private fun Notice(text: String) { + Surface( + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = MaterialTheme.shapes.small, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = text, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.hapi.hint, + modifier = Modifier.padding(8.dp), + ) + } +} + +// -------------------------------------------------------------- previews -- + +@Preview(showBackground = true) +@Composable +private fun SessionConfigSheetPreview() { + HapiTheme { + Surface { + SessionConfigSheetContent( + config = SessionConfigUi( + flavor = "claude", + active = true, + controlledByUser = false, + permissionMode = "acceptEdits", + permissionModes = PermissionModes.forFlavor("claude"), + model = "opus", + modelOptions = ModelCatalog.claudeModelOptions("opus"), + modelOptionsLoading = false, + effort = "high", + effortOptions = ModelCatalog.claudeEffortOptions("high"), + ), + onSetPermissionMode = {}, + onSetModel = {}, + onSetEffort = {}, + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun SessionConfigSheetCodexLoadingPreview() { + HapiTheme { + Surface { + SessionConfigSheetContent( + config = SessionConfigUi( + flavor = "codex", + active = true, + controlledByUser = false, + permissionMode = "read-only", + permissionModes = PermissionModes.forFlavor("codex"), + model = null, + modelOptions = emptyList(), + modelOptionsLoading = true, + effort = null, + effortOptions = null, + ), + onSetPermissionMode = {}, + onSetModel = {}, + onSetEffort = {}, + ) + } + } +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/chat/ToolCardPresentation.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/ToolCardPresentation.kt new file mode 100644 index 0000000000..6e36894ffb --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/ToolCardPresentation.kt @@ -0,0 +1,435 @@ +package app.hapi.companion.feature.chat + +import android.content.res.Resources +import app.hapi.companion.R +import app.hapi.protocol.chat.ChatToolCall +import app.hapi.protocol.chat.getInputStringAny +import app.hapi.protocol.chat.isAskUserQuestionToolName +import app.hapi.protocol.chat.isRequestUserInputToolName +import app.hapi.protocol.chat.truncate +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +/** + * Collapsed tool-card header: icon glyph + title + optional subtitle. Port of + * the presentation registry in `web/src/components/ToolCard/knownTools.tsx` + * (the read-only subset — the web's `minimal` flag maps to "no inline body by + * default" and is a per-card expansion default here). Icons are text glyphs in + * the same family as the event-row emoji the shared protocol presentation + * already emits. + */ +data class ToolCardPresentation( + val icon: String, + val title: String, + val subtitle: String?, +) + +private object ToolIcons { + const val TERMINAL = "💻" + const val READ = "📖" + const val SEARCH = "🔍" + const val EDIT = "✏️" + const val WEB = "🌐" + const val AGENT = "🚀" + const val QUESTION = "❓" + const val PLAN = "📋" + const val IDEA = "💡" + const val PUZZLE = "🧩" + const val MESSAGE = "💬" + const val TEAM = "👥" + const val WARNING = "⚠️" + const val WRENCH = "🔧" +} + +// ---------------------------------------------------------------- helpers -- + +private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject + +private fun JsonElement?.asStringOrNull(): String? = + (this as? JsonPrimitive)?.takeIf { it.isString }?.content + +private fun countLines(text: String): Int = text.split('\n').size + +/** Strip the session root so paths read workspace-relative (web `resolveDisplayPath`). */ +internal fun displayPath(path: String, basePath: String?): String { + if (basePath.isNullOrEmpty()) return path + val root = basePath.trimEnd('/') + return when { + path == root -> "." + path.startsWith("$root/") -> path.removePrefix("$root/") + else -> path + } +} + +private fun basename(path: String): String = + path.trimEnd('/').substringAfterLast('/') + +// ------------------------------------------------------- terminal parsing -- + +private val COMMANDS_WITH_SUBCOMMAND = + setOf("git", "bun", "npm", "pnpm", "yarn", "docker", "systemctl", "cargo", "go") +private val COMMAND_ASSIGNMENT_RE = Regex("^[A-Za-z_][A-Za-z0-9_]*=") +private val AMBIGUOUS_SHELL_RE = Regex("[;&|<>$`(){}\n\r]") + +/** `formatTerminalCommandTitle` (web): the leading executable(+subcommand) of a simple command. */ +internal fun formatTerminalCommandTitle(command: String?): String? { + if (command.isNullOrEmpty() || AMBIGUOUS_SHELL_RE.containsMatchIn(command)) return null + + val parts = command.trim().split(Regex("\\s+")).filter { it.isNotEmpty() } + var index = 0 + while (COMMAND_ASSIGNMENT_RE.containsMatchIn(parts.getOrNull(index) ?: "")) index += 1 + + if (parts.getOrNull(index) == "env") { + index += 1 + while ( + parts.getOrNull(index) == "-i" || + parts.getOrNull(index) == "--ignore-environment" || + COMMAND_ASSIGNMENT_RE.containsMatchIn(parts.getOrNull(index) ?: "") + ) index += 1 + } + if (parts.getOrNull(index) == "sudo") { + index += 1 + while (parts.getOrNull(index) in setOf("-n", "--non-interactive", "-E", "--preserve-env")) index += 1 + } + if (parts.getOrNull(index)?.startsWith("-") == true) return null + + val executable = parts.getOrNull(index)?.let(::basename) ?: return null + if (executable.isEmpty()) return null + + val subcommand = parts.getOrNull(index + 1)?.takeUnless { it.startsWith("-") } + if (subcommand == null || executable !in COMMANDS_WITH_SUBCOMMAND) return executable + if (executable in setOf("bun", "npm", "pnpm", "yarn") && subcommand == "run") { + val script = parts.getOrNull(index + 2) + return if (script != null && !script.startsWith("-")) "$executable run $script" else "$executable run" + } + if (executable == "docker" && subcommand == "compose") { + val action = parts.getOrNull(index + 2) + return if (action != null && !action.startsWith("-")) "docker compose $action" else "docker compose" + } + return "$executable $subcommand" +} + +/** The command string, joining Codex-style `command: string[]` arrays. */ +internal fun terminalCommand(input: JsonElement?): String? { + getInputStringAny(input, listOf("command", "cmd"))?.let { return it } + val array = input.asObjectOrNull()?.get("command") as? JsonArray ?: return null + val parts = array.mapNotNull { it.asStringOrNull()?.takeIf(String::isNotEmpty) } + return if (parts.isEmpty()) null else parts.joinToString(" ") +} + +private fun terminalTitle(input: JsonElement?, description: String?, res: Resources): String { + val command = terminalCommand(input) + if (description != null && description != command) return description + return formatTerminalCommandTitle(command) ?: description ?: res.getString(R.string.tool_terminal) +} + +private fun terminalSubtitle(input: JsonElement?, description: String?, res: Resources): String? { + val command = terminalCommand(input) + return if (command == terminalTitle(input, description, res)) null else command +} + +// ------------------------------------------------------------- questions -- + +private fun questionTitle(input: JsonElement?, res: Resources): String { + val questions = input.asObjectOrNull()?.get("questions") as? JsonArray ?: JsonArray(emptyList()) + if (questions.size > 1) return res.getString(R.string.tool_questions, questions.size) + val header = questions.firstOrNull().asObjectOrNull()?.get("header").asStringOrNull()?.trim().orEmpty() + return header.ifEmpty { res.getString(R.string.tool_question) } +} + +private fun questionSubtitle(input: JsonElement?, res: Resources): String? { + val questions = input.asObjectOrNull()?.get("questions") as? JsonArray ?: JsonArray(emptyList()) + val question = questions.firstOrNull().asObjectOrNull()?.get("question").asStringOrNull()?.trim().orEmpty() + if (questions.size > 1 && question.isNotEmpty()) { + return res.getString(R.string.tool_questions_more, truncate(question, 100), questions.size - 1) + } + return question.takeIf { it.isNotEmpty() }?.let { truncate(it, 120) } +} + +// --------------------------------------------------------------- MCP names -- + +private fun snakeToTitle(value: String): String = value + .split('_') + .filter { it.isNotEmpty() } + .joinToString(" ") { part -> part.lowercase().replaceFirstChar { it.uppercaseChar() } } + +private fun mcpTitle(toolName: String): String { + val withoutPrefix = toolName.removePrefix("mcp__") + val parts = withoutPrefix.split("__") + return if (parts.size >= 2) { + "MCP: ${snakeToTitle(parts[0])} ${snakeToTitle(parts.drop(1).joinToString("_"))}" + } else { + "MCP: ${snakeToTitle(withoutPrefix)}" + } +} + +// ------------------------------------------------------------ entry point -- + +@Suppress("CyclomaticComplexMethod", "LongMethod") +fun toolCardPresentation( + tool: ChatToolCall, + basePath: String?, + /** Localizes the semantic fallback titles (B-M5a). */ + res: Resources, +): ToolCardPresentation { + val input = tool.input + val name = tool.name + val description = tool.description + + if (name.startsWith("mcp__")) { + return ToolCardPresentation(ToolIcons.PUZZLE, mcpTitle(name), null) + } + if (isAskUserQuestionToolName(name) || isRequestUserInputToolName(name)) { + return ToolCardPresentation(ToolIcons.QUESTION, questionTitle(input, res), questionSubtitle(input, res)) + } + + fun filePathTitle(keys: List, fallback: String): String = + getInputStringAny(input, keys)?.let { displayPath(it, basePath) } ?: fallback + + when (name) { + "Bash", "CodexBash", "shell_command", "run_shell_command" -> { + // CodexBash single parsed read renders as the file it reads. + if (name == "CodexBash") { + val parsed = input.asObjectOrNull()?.get("parsed_cmd") as? JsonArray + val first = parsed?.singleOrNull().asObjectOrNull() + if (first?.get("type").asStringOrNull() == "read") { + first?.get("name").asStringOrNull()?.let { file -> + return ToolCardPresentation( + ToolIcons.READ, + displayPath(file, basePath), + terminalSubtitle(input, description, res), + ) + } + } + } + return ToolCardPresentation( + ToolIcons.TERMINAL, + terminalTitle(input, description, res), + terminalSubtitle(input, description, res), + ) + } + + "Read" -> return ToolCardPresentation( + ToolIcons.READ, filePathTitle(listOf("file_path", "path", "file"), res.getString(R.string.tool_read_file)), null, + ) + + "NotebookRead" -> return ToolCardPresentation( + ToolIcons.READ, filePathTitle(listOf("notebook_path"), res.getString(R.string.tool_read_notebook)), null, + ) + + "Edit" -> return ToolCardPresentation( + ToolIcons.EDIT, filePathTitle(listOf("file_path", "path"), res.getString(R.string.tool_edit_file)), null, + ) + + "MultiEdit" -> { + val file = getInputStringAny(input, listOf("file_path", "path")) + ?: return ToolCardPresentation(ToolIcons.EDIT, res.getString(R.string.tool_edit_file), null) + val count = (input.asObjectOrNull()?.get("edits") as? JsonArray)?.size ?: 0 + val path = displayPath(file, basePath) + return ToolCardPresentation( + ToolIcons.EDIT, + if (count > 1) res.getString(R.string.tool_edits_count, path, count) else path, + null, + ) + } + + "Write" -> { + val content = getInputStringAny(input, listOf("content", "text")) + val subtitle = content?.let { + val lines = countLines(it) + if (lines > 1) { + res.getString(R.string.tool_write_lines, lines) + } else { + res.getString(R.string.tool_write_chars, it.length) + } + } + return ToolCardPresentation( + ToolIcons.EDIT, filePathTitle(listOf("file_path", "path"), res.getString(R.string.tool_write_file)), subtitle, + ) + } + + "NotebookEdit" -> return ToolCardPresentation( + ToolIcons.EDIT, + filePathTitle(listOf("notebook_path"), res.getString(R.string.tool_edit_notebook)), + getInputStringAny(input, listOf("edit_mode"))?.let { "mode: $it" }, + ) + + "Glob" -> return ToolCardPresentation( + ToolIcons.SEARCH, getInputStringAny(input, listOf("pattern")) ?: res.getString(R.string.tool_search_files), null, + ) + + "Grep" -> { + val pattern = getInputStringAny(input, listOf("pattern")) + return ToolCardPresentation( + ToolIcons.SEARCH, pattern?.let { "grep(pattern: $it)" } ?: res.getString(R.string.tool_search_content), null, + ) + } + + "LS" -> return ToolCardPresentation( + ToolIcons.SEARCH, filePathTitle(listOf("path"), res.getString(R.string.tool_list_files)), null, + ) + + "WebFetch" -> { + val url = getInputStringAny(input, listOf("url")) + ?: return ToolCardPresentation(ToolIcons.WEB, res.getString(R.string.tool_web_fetch), null) + val host = Regex("^[a-zA-Z][a-zA-Z0-9+.-]*://([^/]+)").find(url)?.groupValues?.get(1) ?: url + return ToolCardPresentation(ToolIcons.WEB, host, url) + } + + "WebSearch" -> { + val query = getInputStringAny(input, listOf("query")) + return ToolCardPresentation(ToolIcons.WEB, query ?: res.getString(R.string.tool_web_search), query?.let { truncate(it, 80) }) + } + + "Task", "Agent" -> { + val inputName = getInputStringAny(input, listOf("name")) + val teamName = getInputStringAny(input, listOf("team_name")) + val title = when { + name == "Task" && inputName != null && teamName != null -> + res.getString(R.string.tool_agent_named, inputName) + else -> getInputStringAny(input, listOf("description")) + ?: res.getString(if (name == "Task") R.string.tool_task else R.string.tool_launch_agent) + } + val subtitle = getInputStringAny(input, listOf("prompt"))?.let { truncate(it, 120) } + ?: getInputStringAny(input, listOf("subagent_type")) + return ToolCardPresentation(ToolIcons.AGENT, title, subtitle) + } + + "CodexAgent", "spawn_agent", "resume_agent", "wait_agent", "close_agent", "interrupt_agent" -> { + val title = res.getString( + when (name) { + "spawn_agent" -> R.string.tool_spawn_agent + "resume_agent" -> R.string.tool_resume_agent + "wait_agent" -> R.string.tool_wait_agent + "close_agent" -> R.string.tool_close_agent + "interrupt_agent" -> R.string.tool_interrupt_agent + else -> R.string.tool_agent + }, + ) + val prompt = getInputStringAny(input, listOf("prompt", "summary")) + return ToolCardPresentation(ToolIcons.AGENT, title, prompt?.let { truncate(it, 120) }) + } + + "SendMessage", "send_input", "send_message", "followup_task" -> { + val recipient = getInputStringAny(input, listOf("recipient")) + val msgType = getInputStringAny(input, listOf("type")) + val title = when { + msgType == "broadcast" -> res.getString(R.string.tool_broadcast) + msgType == "shutdown_request" -> res.getString( + R.string.tool_shutdown, + recipient ?: res.getString(R.string.tool_shutdown_fallback_recipient), + ) + msgType == "shutdown_response" -> res.getString(R.string.tool_shutdown_response) + recipient != null -> res.getString(R.string.tool_message_named, recipient) + else -> res.getString(R.string.tool_message_agent) + } + val summary = getInputStringAny(input, listOf("summary")) + return ToolCardPresentation(ToolIcons.MESSAGE, title, summary?.let { truncate(it, 120) }) + } + + "list_agents" -> return ToolCardPresentation(ToolIcons.TEAM, res.getString(R.string.tool_list_agents), null) + + "TeamCreate" -> { + val teamName = getInputStringAny(input, listOf("team_name")) + return ToolCardPresentation( + ToolIcons.TEAM, + teamName?.let { res.getString(R.string.tool_team_named, it) } + ?: res.getString(R.string.tool_create_team), + getInputStringAny(input, listOf("description")), + ) + } + + "TeamDelete" -> return ToolCardPresentation(ToolIcons.TEAM, res.getString(R.string.tool_delete_team), null) + + "TodoWrite" -> return ToolCardPresentation(ToolIcons.IDEA, res.getString(R.string.tool_todo_list), null) + + "update_plan" -> return ToolCardPresentation(ToolIcons.PLAN, res.getString(R.string.tool_plan), null) + + "ExitPlanMode", "exit_plan_mode" -> return ToolCardPresentation(ToolIcons.PLAN, res.getString(R.string.tool_plan_proposal), null) + + "Skill" -> { + val skill = getInputStringAny(input, listOf("skill")) + return ToolCardPresentation( + ToolIcons.PUZZLE, + skill?.let { res.getString(R.string.tool_skill_named, it) } ?: res.getString(R.string.tool_skill), + null, + ) + } + + "CodexReasoning" -> return ToolCardPresentation( + ToolIcons.IDEA, getInputStringAny(input, listOf("title")) ?: res.getString(R.string.tool_reasoning), null, + ) + + "CodexPermission" -> { + val permissionTool = getInputStringAny(input, listOf("tool")) + return ToolCardPresentation( + ToolIcons.QUESTION, + permissionTool?.let { res.getString(R.string.tool_permission_named, it) } + ?: res.getString(R.string.tool_permission_request), + getInputStringAny(input, listOf("message", "command")), + ) + } + + "CodexPatch" -> { + val changes = input.asObjectOrNull()?.get("changes").asObjectOrNull() + val files = changes?.keys?.toList().orEmpty() + val subtitle = files.firstOrNull()?.let { first -> + val display = basename(displayPath(first, basePath)) + if (files.size > 1) "$display (+${files.size - 1})" else display + } + return ToolCardPresentation(ToolIcons.EDIT, res.getString(R.string.tool_apply_changes), subtitle) + } + + "CodexDiff" -> { + val unified = getInputStringAny(input, listOf("unified_diff")) + val subtitle = unified?.lineSequence() + ?.firstOrNull { it.startsWith("+++ ") } + ?.removePrefix("+++ ")?.removePrefix("b/") + ?.let { it.substringAfterLast('/') } + return ToolCardPresentation(ToolIcons.EDIT, res.getString(R.string.tool_diff), subtitle) + } + + "AgyTaskLog" -> { + val task = getInputStringAny(input, listOf("task")) + return ToolCardPresentation( + ToolIcons.MESSAGE, + task?.let { res.getString(R.string.tool_task_log, it) } + ?: res.getString(R.string.tool_inspecting_task_log), + null, + ) + } + + "AgyAsyncTask" -> return ToolCardPresentation(ToolIcons.PLAN, description ?: res.getString(R.string.tool_background_task), null) + + "AgyError" -> return ToolCardPresentation(ToolIcons.WARNING, description ?: res.getString(R.string.tool_error), null) + } + + // Generic fallback (web `getToolPresentation` tail): promote a semantic + // label when an ACP agent's title is the verbatim argument. + val filePath = getInputStringAny(input, listOf("file_path", "path", "filePath", "file")) + val command = getInputStringAny(input, listOf("command", "cmd")) + val pattern = getInputStringAny(input, listOf("pattern")) + val url = getInputStringAny(input, listOf("url")) + val query = getInputStringAny(input, listOf("query")) + val nameInput = getInputStringAny(input, listOf("name")) + val subtitle = filePath ?: command ?: pattern ?: url ?: query ?: nameInput + + var title = description ?: name + if (subtitle != null && subtitle == title) { + title = when { + filePath != null -> res.getString(R.string.tool_read_file) + command != null -> res.getString(R.string.tool_run_shell) + pattern != null -> res.getString(R.string.tool_search) + url != null -> res.getString(R.string.tool_open_url) + query != null -> res.getString(R.string.tool_query) + else -> title + } + } + return ToolCardPresentation( + ToolIcons.WRENCH, + title, + subtitle?.takeIf { it != title }?.let { truncate(it, 80) }, + ) +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/chat/attachments/AttachmentPickerSheet.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/attachments/AttachmentPickerSheet.kt new file mode 100644 index 0000000000..795fc36649 --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/attachments/AttachmentPickerSheet.kt @@ -0,0 +1,81 @@ +package app.hapi.companion.feature.chat.attachments + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import app.hapi.companion.R +import app.hapi.companion.ui.theme.HapiTheme +import app.hapi.companion.ui.theme.hapi + +/** + * The composer "+" sheet (B-M3f): three attachment sources. The launchers + * (photo picker / TakePicture / OpenDocument) live in the screen — this is + * pure chrome. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AttachmentPickerSheet( + onDismiss: () -> Unit, + onPickPhotos: () -> Unit, + onTakePhoto: () -> Unit, + onPickFiles: () -> Unit, +) { + ModalBottomSheet(onDismissRequest = onDismiss) { + Column(modifier = Modifier.padding(bottom = 20.dp)) { + Text( + text = stringResource(R.string.chat_picker_title), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.hapi.hint, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 6.dp), + ) + PickerRow(glyph = "🖼", label = stringResource(R.string.chat_picker_photos), onClick = { onDismiss(); onPickPhotos() }) + PickerRow(glyph = "📷", label = stringResource(R.string.chat_picker_camera), onClick = { onDismiss(); onTakePhoto() }) + PickerRow(glyph = "📄", label = stringResource(R.string.chat_picker_files), onClick = { onDismiss(); onPickFiles() }) + } + } +} + +@Composable +private fun PickerRow(glyph: String, label: String, onClick: () -> Unit) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 20.dp, vertical = 14.dp), + ) { + Text(text = glyph, fontSize = 20.sp) + Spacer(modifier = Modifier.width(16.dp)) + Text(text = label, style = MaterialTheme.typography.bodyLarge) + } +} + +@Preview(showBackground = true) +@Composable +private fun AttachmentPickerRowsPreview() { + HapiTheme { + Surface { + Column { + PickerRow(glyph = "🖼", label = "Photo library", onClick = {}) + PickerRow(glyph = "📷", label = "Camera", onClick = {}) + PickerRow(glyph = "📄", label = "Files", onClick = {}) + } + } + } +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/chat/attachments/AttachmentPolicy.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/attachments/AttachmentPolicy.kt new file mode 100644 index 0000000000..335fc537c0 --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/attachments/AttachmentPolicy.kt @@ -0,0 +1,159 @@ +package app.hapi.companion.feature.chat.attachments + +import java.util.Base64 + +/** + * Pure sizing/compression policy for composer attachments (B-M3f) — no + * Android types, so every decision is JVM-testable. + * + * Wire contract (`rest.md` uploads): `POST /api/sessions/:id/upload` is + * JSON + base64 with a hard 50 MB decoded limit. The web sends originals + * (`attachmentAdapter.ts`); on mobile data that is hostile for camera + * photos, so images above [IMAGE_COMPRESS_THRESHOLD_BYTES] are downscaled + * to [MAX_IMAGE_DIMENSION] px and re-encoded as JPEG ([COMPRESS_JPEG_QUALITY]) + * before upload. Non-image files always keep their original bytes. + */ +object AttachmentPolicy { + + /** Hub upload ceiling (decoded bytes) — `MAX_UPLOAD_BYTES` on the web. */ + const val MAX_UPLOAD_BYTES: Long = 50L * 1024 * 1024 + + /** Images larger than this get downscaled + JPEG-recompressed. */ + const val IMAGE_COMPRESS_THRESHOLD_BYTES: Long = 4L * 1024 * 1024 + + /** Longest edge after downscaling. */ + const val MAX_IMAGE_DIMENSION: Int = 2048 + + /** JPEG quality for recompressed uploads. */ + const val COMPRESS_JPEG_QUALITY: Int = 85 + + /** + * Longest edge of the thumbnail embedded as `AttachmentMetadata.previewUrl` + * (a JPEG data URL). The web embeds the full original (≤ 5 MB) there; a + * small thumb keeps `SendMessageRequest` bodies tiny while still giving + * every client (web included) something to render in the user bubble. + */ + const val PREVIEW_MAX_DIMENSION: Int = 512 + + /** JPEG quality for the embedded preview thumbnail. */ + const val PREVIEW_JPEG_QUALITY: Int = 80 + + /** + * Heap guard for reading picked content whose provider reports no size: + * recompressible images may legitimately exceed 50 MB pre-compression, + * everything else stops at the wire cap (see [readCapFor]). + */ + const val MAX_IMAGE_SOURCE_BYTES: Long = 192L * 1024 * 1024 + + /** + * Formats that are safe to decode + re-encode as a still JPEG. GIFs are + * excluded (recompression would drop animation) and SVG/unknown types + * are not bitmap-decodable — those upload as originals or get rejected. + */ + private val RECOMPRESSIBLE_IMAGE_MIMES = setOf( + "image/jpeg", + "image/jpg", + "image/png", + "image/webp", + "image/heic", + "image/heif", + ) + + /** What the preparer should do with a picked file. */ + sealed interface Plan { + /** Upload the original bytes untouched. */ + data object KeepOriginal : Plan + + /** Downscale to [MAX_IMAGE_DIMENSION] + JPEG-recompress, then upload. */ + data object CompressImage : Plan + + /** Over the 50 MB wire cap and not recoverable by compression. */ + data object Reject : Plan + } + + /** + * Decide the handling for a file of [mimeType] and [sizeBytes]. + * + * Recompressible images over the threshold are compressed even when the + * original exceeds 50 MB — downscaling brings any camera photo far under + * the cap (the post-compression size is re-checked by the preparer). + */ + fun planFor(mimeType: String, sizeBytes: Long): Plan = when { + isRecompressibleImageMime(mimeType) && sizeBytes > IMAGE_COMPRESS_THRESHOLD_BYTES -> + Plan.CompressImage + sizeBytes > MAX_UPLOAD_BYTES -> Plan.Reject + else -> Plan.KeepOriginal + } + + /** How many bytes to read at most before giving up on a pick as too large. */ + fun readCapFor(mimeType: String): Long = + if (isRecompressibleImageMime(mimeType)) MAX_IMAGE_SOURCE_BYTES else MAX_UPLOAD_BYTES + + fun isImageMime(mimeType: String): Boolean = mimeType.startsWith("image/") + + fun isRecompressibleImageMime(mimeType: String): Boolean = + mimeType.lowercase() in RECOMPRESSIBLE_IMAGE_MIMES + + /** + * Power-of-two `BitmapFactory.Options.inSampleSize` so the decoded bitmap + * is the smallest one whose longest edge is still ≥ [maxDimension] + * (exact scaling happens after decode via [scaledDimensions]). + */ + fun sampleSizeFor(width: Int, height: Int, maxDimension: Int): Int { + if (width <= 0 || height <= 0) return 1 + var sample = 1 + val longest = maxOf(width, height) + while (longest / (sample * 2) >= maxDimension) { + sample *= 2 + } + return sample + } + + /** Final (width, height) with the longest edge clamped to [maxDimension]. */ + fun scaledDimensions(width: Int, height: Int, maxDimension: Int): Pair { + val longest = maxOf(width, height) + if (longest <= maxDimension || longest <= 0) return width to height + val scale = maxDimension.toDouble() / longest + val w = (width * scale).toInt().coerceAtLeast(1) + val h = (height * scale).toInt().coerceAtLeast(1) + return w to h + } + + /** + * Recompression re-encodes as JPEG, so the advertised filename swaps its + * extension to `.jpg` (a `shot.png` upload that is actually JPEG bytes + * would confuse the agent reading it from disk). + */ + fun compressedFilename(original: String): String { + val dot = original.lastIndexOf('.') + val stem = if (dot > 0) original.substring(0, dot) else original + return "$stem.jpg" + } + + /** `data:;base64,<...>` — the wire `previewUrl` format (web parity). */ + fun dataUrl(mimeType: String, bytes: ByteArray): String = + "data:$mimeType;base64,${Base64.getEncoder().encodeToString(bytes)}" + + /** + * The base64 payload of a data URL, or null when [url] is not one. + * Accepts any `data:*;base64,` head — web previews are `data:image/png` + * etc., Android-authored ones are always JPEG. + */ + fun base64FromDataUrl(url: String): String? { + if (!url.startsWith("data:")) return null + val comma = url.indexOf(',') + if (comma < 0) return null + if (!url.substring(0, comma).endsWith(";base64")) return null + return url.substring(comma + 1).takeIf { it.isNotEmpty() } + } + + /** Decoded bytes of a base64 data URL, or null when unparseable. */ + fun bytesFromDataUrl(url: String): ByteArray? { + val base64 = base64FromDataUrl(url) ?: return null + return try { + Base64.getDecoder().decode(base64) + } catch (_: IllegalArgumentException) { + null + } + } +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/chat/attachments/AttachmentPreparer.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/attachments/AttachmentPreparer.kt new file mode 100644 index 0000000000..6d31e2961f --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/attachments/AttachmentPreparer.kt @@ -0,0 +1,246 @@ +package app.hapi.companion.feature.chat.attachments + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import android.provider.OpenableColumns +import android.webkit.MimeTypeMap +import androidx.core.content.FileProvider +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.InputStream +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.UUID +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** Outcome of preparing one picked/captured file for upload. */ +sealed interface PrepareResult { + data class Ready(val attachment: PreparedAttachment) : PrepareResult + + /** Over the 50 MB wire cap (and not recoverable by image compression). */ + data class TooLarge(val filename: String, val sizeBytes: Long) : PrepareResult + + /** The content provider would not give us the bytes. */ + data class Unreadable(val filename: String) : PrepareResult +} + +/** + * A pending camera capture: the FileProvider [uri] handed to the camera app + + * its backing [file] ([file] is exposed so the screen can `rememberSaveable` + * the pending capture across rotation/process death while the camera is open). + */ +class CameraCapture(val uri: Uri, val file: File) { + /** Delete the cache-file scratch once the capture was ingested (or abandoned). */ + fun discard() { + runCatching { file.delete() } + } +} + +/** + * Reads picked content into [PreparedAttachment]s ready for + * [ComposerAttachments.add] (B-M3f). All policy decisions live in the pure + * [AttachmentPolicy]; this class supplies the Android parts: ContentResolver + * metadata + bytes, `BitmapFactory` downscaling, JPEG re-encode, thumbnail + * generation, and the FileProvider scratch file for `TakePicture`. + * + * Compression stance (differs from web, which uploads originals): images over + * [AttachmentPolicy.IMAGE_COMPRESS_THRESHOLD_BYTES] are downscaled to + * [AttachmentPolicy.MAX_IMAGE_DIMENSION] px JPEG — phone photos are 5–15 MB + * of mostly-wasted agent context on mobile data. Non-image files always keep + * their original bytes; anything still over 50 MB is rejected. + */ +class AttachmentPreparer( + context: Context, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, +) { + private val appContext = context.applicationContext + + suspend fun prepare(uri: Uri): PrepareResult = withContext(ioDispatcher) { + val resolver = appContext.contentResolver + + var displayName: String? = null + var statedSize: Long? = null + runCatching { + resolver.query(uri, null, null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) { + val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (nameIndex >= 0 && !cursor.isNull(nameIndex)) displayName = cursor.getString(nameIndex) + val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE) + if (sizeIndex >= 0 && !cursor.isNull(sizeIndex)) statedSize = cursor.getLong(sizeIndex) + } + } + } + + val mimeType = resolver.getType(uri) + ?: displayName?.let { guessMimeFromName(it) } + ?: "application/octet-stream" + val filename = displayName ?: fallbackFilename(mimeType) + + // Reject before reading when the provider already told us it's hopeless. + statedSize?.let { size -> + if (AttachmentPolicy.planFor(mimeType, size) == AttachmentPolicy.Plan.Reject) { + return@withContext PrepareResult.TooLarge(filename, size) + } + } + + // Providers may report no size — read behind a capped stream so a + // surprise multi-GB pick cannot OOM the process. + val readCap = AttachmentPolicy.readCapFor(mimeType) + val original = try { + resolver.openInputStream(uri)?.use { readUpTo(it, readCap) } + } catch (_: Exception) { + return@withContext PrepareResult.Unreadable(filename) + } catch (_: OutOfMemoryError) { + return@withContext PrepareResult.Unreadable(filename) + } ?: return@withContext PrepareResult.Unreadable(filename) + + if (original.overflowed) { + return@withContext PrepareResult.TooLarge(filename, statedSize ?: readCap) + } + prepareBytes(filename, mimeType, original.bytes) + } + + private class CappedRead(val bytes: ByteArray, val overflowed: Boolean) + + /** Read the full stream, or stop with `overflowed` once [cap] bytes are exceeded. */ + private fun readUpTo(stream: InputStream, cap: Long): CappedRead { + val out = ByteArrayOutputStream() + val buffer = ByteArray(64 * 1024) + var total = 0L + while (true) { + val read = stream.read(buffer) + if (read < 0) break + total += read + if (total > cap) return CappedRead(ByteArray(0), overflowed = true) + out.write(buffer, 0, read) + } + return CappedRead(out.toByteArray(), overflowed = false) + } + + /** Policy application over in-memory bytes (shared by every pick source). */ + private fun prepareBytes(filename: String, mimeType: String, original: ByteArray): PrepareResult { + val id = "att-${UUID.randomUUID()}" + return when (AttachmentPolicy.planFor(mimeType, original.size.toLong())) { + AttachmentPolicy.Plan.Reject -> PrepareResult.TooLarge(filename, original.size.toLong()) + + AttachmentPolicy.Plan.KeepOriginal -> PrepareResult.Ready( + PreparedAttachment( + id = id, + filename = filename, + mimeType = mimeType, + bytes = original, + previewBytes = if (AttachmentPolicy.isImageMime(mimeType)) thumbnail(original) else null, + ), + ) + + AttachmentPolicy.Plan.CompressImage -> { + val compressed = recompress(original) + when { + compressed != null -> PrepareResult.Ready( + PreparedAttachment( + id = id, + filename = AttachmentPolicy.compressedFilename(filename), + mimeType = "image/jpeg", + bytes = compressed, + previewBytes = thumbnail(compressed), + ), + ) + // Undecodable (e.g. HEIC on API 26/27): fall back to the + // original when it fits the wire cap, reject otherwise. + original.size <= AttachmentPolicy.MAX_UPLOAD_BYTES -> PrepareResult.Ready( + PreparedAttachment( + id = id, + filename = filename, + mimeType = mimeType, + bytes = original, + previewBytes = thumbnail(original), + ), + ) + else -> PrepareResult.TooLarge(filename, original.size.toLong()) + } + } + } + } + + /** Downscale to ≤ [AttachmentPolicy.MAX_IMAGE_DIMENSION] px JPEG, or null when undecodable/still too big. */ + private fun recompress(original: ByteArray): ByteArray? { + val encoded = encodeScaledJpeg( + original, + AttachmentPolicy.MAX_IMAGE_DIMENSION, + AttachmentPolicy.COMPRESS_JPEG_QUALITY, + ) ?: return null + // Belt-and-braces: a 2048px JPEG is always far under 50 MB, but the + // wire cap is a hard contract. + return encoded.takeIf { it.size <= AttachmentPolicy.MAX_UPLOAD_BYTES } + } + + /** Chip/bubble thumbnail (also the wire `previewUrl` payload); null when undecodable. */ + private fun thumbnail(imageBytes: ByteArray): ByteArray? = encodeScaledJpeg( + imageBytes, + AttachmentPolicy.PREVIEW_MAX_DIMENSION, + AttachmentPolicy.PREVIEW_JPEG_QUALITY, + ) + + private fun encodeScaledJpeg(source: ByteArray, maxDimension: Int, quality: Int): ByteArray? { + try { + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeByteArray(source, 0, source.size, bounds) + if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null + + val options = BitmapFactory.Options().apply { + inSampleSize = AttachmentPolicy.sampleSizeFor(bounds.outWidth, bounds.outHeight, maxDimension) + } + val decoded = BitmapFactory.decodeByteArray(source, 0, source.size, options) ?: return null + val (targetW, targetH) = AttachmentPolicy.scaledDimensions(decoded.width, decoded.height, maxDimension) + val scaled = if (targetW != decoded.width || targetH != decoded.height) { + Bitmap.createScaledBitmap(decoded, targetW, targetH, true).also { + if (it !== decoded) decoded.recycle() + } + } else { + decoded + } + val out = ByteArrayOutputStream() + val ok = scaled.compress(Bitmap.CompressFormat.JPEG, quality, out) + scaled.recycle() + return if (ok) out.toByteArray() else null + } catch (_: Exception) { + return null + } catch (_: OutOfMemoryError) { + return null + } + } + + // ------------------------------------------------------------- camera -- + + /** + * Scratch target for `ActivityResultContracts.TakePicture`: a cache file + * under `cache/attachments/` exposed through the app's FileProvider + * (authority `.attachments`, `attachment_file_paths.xml`). + * Call [CameraCapture.discard] after ingesting (or on cancel) — captures + * never persist past the pick. + */ + fun newCameraCapture(): CameraCapture { + val dir = File(appContext.cacheDir, "attachments").apply { mkdirs() } + val stamp = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date()) + val file = File(dir, "camera-$stamp.jpg") + val uri = FileProvider.getUriForFile(appContext, "${appContext.packageName}.attachments", file) + return CameraCapture(uri, file) + } + + private fun guessMimeFromName(name: String): String? { + val extension = name.substringAfterLast('.', "").lowercase(Locale.US) + if (extension.isEmpty()) return null + return MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) + } + + private fun fallbackFilename(mimeType: String): String { + val extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType) + val stamp = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date()) + return if (extension != null) "attachment-$stamp.$extension" else "attachment-$stamp" + } +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/chat/attachments/AttachmentThumbnails.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/attachments/AttachmentThumbnails.kt new file mode 100644 index 0000000000..38e466ccfa --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/attachments/AttachmentThumbnails.kt @@ -0,0 +1,68 @@ +package app.hapi.companion.feature.chat.attachments + +import android.graphics.BitmapFactory +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** Async decode outcome for a wire `previewUrl` thumbnail. */ +sealed interface PreviewImage { + /** Decode still running — render a neutral placeholder. */ + data object Loading : PreviewImage + + data class Ready(val bitmap: ImageBitmap) : PreviewImage + + /** Not a data URL / undecodable — fall back to the filename chip. */ + data object Unavailable : PreviewImage +} + +/** + * Decode an `AttachmentMetadata.previewUrl` data URL into a bubble thumbnail. + * + * Android-authored previews are ≤ 512 px JPEGs, but web-authored ones embed + * the full original (up to 5 MB), so the decode downsamples to [maxDimension] + * and runs off the main thread. + */ +@Composable +fun rememberPreviewImage(previewUrl: String?, maxDimension: Int = 512): State = + produceState(initialValue = PreviewImage.Loading, previewUrl) { + if (previewUrl == null) { + value = PreviewImage.Unavailable + return@produceState + } + value = withContext(Dispatchers.Default) { + val bytes = AttachmentPolicy.bytesFromDataUrl(previewUrl) + val bitmap = bytes?.let { decodeDownsampled(it, maxDimension) } + if (bitmap != null) PreviewImage.Ready(bitmap) else PreviewImage.Unavailable + } + } + +/** + * Synchronous decode for composer chip thumbs — [ComposerAttachmentUi.previewBytes] + * are preparer-made ≤ 512 px JPEGs, cheap enough to decode inline. + */ +@Composable +fun rememberChipThumbnail(previewBytes: ByteArray?): ImageBitmap? = remember(previewBytes) { + previewBytes?.let { decodeDownsampled(it, AttachmentPolicy.PREVIEW_MAX_DIMENSION) } +} + +private fun decodeDownsampled(bytes: ByteArray, maxDimension: Int): ImageBitmap? { + return try { + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds) + if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null + val options = BitmapFactory.Options().apply { + inSampleSize = AttachmentPolicy.sampleSizeFor(bounds.outWidth, bounds.outHeight, maxDimension) + } + BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options)?.asImageBitmap() + } catch (_: Exception) { + null + } catch (_: OutOfMemoryError) { + null + } +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/chat/attachments/ComposerAttachments.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/attachments/ComposerAttachments.kt new file mode 100644 index 0000000000..69ee63fb7f --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/attachments/ComposerAttachments.kt @@ -0,0 +1,236 @@ +package app.hapi.companion.feature.chat.attachments + +import app.hapi.data.api.AttachmentUploadApi +import app.hapi.protocol.wire.AttachmentMetadata +import java.util.Base64 +import kotlin.coroutines.cancellation.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * A picked file after platform preparation (ContentResolver read + optional + * image downscale): everything the upload flow needs, no Android types — + * JVM tests feed these directly. + */ +class PreparedAttachment( + val id: String, + /** Display/upload filename (extension rewritten to `.jpg` when compressed). */ + val filename: String, + val mimeType: String, + /** The exact bytes that will upload (post-compression when applicable). */ + val bytes: ByteArray, + /** Small JPEG thumbnail for the chip + wire `previewUrl`; null for non-images. */ + val previewBytes: ByteArray? = null, +) { + val sizeBytes: Long get() = bytes.size.toLong() +} + +/** Chip lifecycle: uploading → ready (or failed → retry/remove). */ +enum class ComposerAttachmentStatus { Uploading, Ready, Failed } + +/** One composer attachment chip. */ +@Suppress("ArrayInDataClass") +data class ComposerAttachmentUi( + val id: String, + val filename: String, + val mimeType: String, + val sizeBytes: Long, + /** JPEG thumbnail bytes for image picks; null renders a file glyph. */ + val previewBytes: ByteArray?, + val status: ComposerAttachmentStatus, +) + +/** + * Composer attachment tray (B-M3f): upload-on-pick state machine feeding + * `SendMessageRequest.attachments`. + * + * Mirrors the web `attachmentAdapter.ts` flow with mobile adjustments: + * + * - [add] uploads immediately (`POST upload`, JSON + base64) and tracks the + * chip through [ComposerAttachmentStatus]; failures keep the payload bytes + * for [retry], successes drop them (only the small preview stays resident). + * - [remove] deletes the uploaded file best-effort (`POST upload/delete`); + * removing a chip whose upload is still in flight lets the upload finish + * and then deletes the orphan (web `cancelledAttachmentIds` semantics). + * - [consume] converts every Ready chip into [AttachmentMetadata] for the + * send body — `previewUrl` is a small JPEG data URL + * ([AttachmentPolicy.PREVIEW_MAX_DIMENSION]) so user bubbles render + * thumbnails on every client. + * - **Drafts (v1 simplification)**: unlike the web (IndexedDB attachment + * drafts), attachments never persist. Leaving the chat for good discards + * un-sent chips via [discardAllDetached] after best-effort hub deletes; + * text drafts alone survive. + * - **Inactive sessions (v1 simplification)**: the hub's upload route + * requires an active session, and unlike the web this tray does not + * resume-then-upload — a pick on an inactive session settles Failed; + * sending any text auto-resumes (B-M3ab), after which the chip's retry + * succeeds. Uploaded paths are absolute on the agent machine, so they + * stay readable across a resume (even one that supersedes the id). + */ +class ComposerAttachments( + private val api: AttachmentUploadApi, + private val sessionId: String, + private val scope: CoroutineScope, + /** Base64 of up to 50 MB happens off the main thread. */ + private val encodeDispatcher: CoroutineDispatcher = Dispatchers.Default, + /** [discardAllDetached] launch target; null ⇒ GlobalScope (production). */ + private val detachedCleanupScope: CoroutineScope? = null, +) { + private class Entry( + val ui: ComposerAttachmentUi, + /** Hub upload path once Ready. */ + val path: String? = null, + /** Upload payload, retained only until the upload succeeds (retry source). */ + val bytes: ByteArray? = null, + ) + + private val entries = MutableStateFlow>(emptyList()) + + /** Chip states for the composer row. */ + val items: StateFlow> = entries + .map { list -> list.map { it.ui } } + .stateIn(scope, SharingStarted.Eagerly, emptyList()) + + /** Convenience for send gating: chips exist and every one settled Ready. */ + fun allReady(): Boolean = + entries.value.let { list -> list.isNotEmpty() && list.all { it.ui.status == ComposerAttachmentStatus.Ready } } + + /** True while any chip is Uploading or Failed — send must wait or resolve. */ + fun hasUnsettled(): Boolean = + entries.value.any { it.ui.status != ComposerAttachmentStatus.Ready } + + /** Add a prepared pick to the tray and start its upload. */ + fun add(prepared: PreparedAttachment) { + val ui = ComposerAttachmentUi( + id = prepared.id, + filename = prepared.filename, + mimeType = prepared.mimeType, + sizeBytes = prepared.sizeBytes, + previewBytes = prepared.previewBytes, + status = ComposerAttachmentStatus.Uploading, + ) + entries.update { it + Entry(ui, bytes = prepared.bytes) } + upload(prepared.id, prepared.filename, prepared.mimeType, prepared.bytes) + } + + /** Failed chip tap: re-fire the upload with the retained bytes. */ + fun retry(id: String) { + var payload: Entry? = null + entries.update { list -> + val entry = list.firstOrNull { + it.ui.id == id && it.ui.status == ComposerAttachmentStatus.Failed && it.bytes != null + } ?: return@update list + payload = entry + list.map { + if (it.ui.id == id) Entry(it.ui.copy(status = ComposerAttachmentStatus.Uploading), bytes = it.bytes) + else it + } + } + val entry = payload ?: return + upload(id, entry.ui.filename, entry.ui.mimeType, entry.bytes!!) + } + + /** + * Drop a chip. An already-uploaded file is deleted best-effort; an + * in-flight upload deletes its result on completion (see [upload]). + */ + fun remove(id: String) { + var removed: Entry? = null + entries.update { list -> + removed = list.firstOrNull { it.ui.id == id } + list.filterNot { it.ui.id == id } + } + removed?.path?.let { path -> + scope.launch { runCatching { api.deleteUpload(sessionId, path) } } + } + } + + /** + * Take every Ready chip as send metadata, clearing them from the tray + * (unsettled chips stay put — the ViewModel guards against calling with + * any pending, but a race can settle one to Failed in between). + * + * @return the metadata list, or null when nothing was ready. + */ + fun consume(): List? { + var taken: List = emptyList() + entries.update { list -> + taken = list.filter { it.ui.status == ComposerAttachmentStatus.Ready && it.path != null } + list - taken.toSet() + } + if (taken.isEmpty()) return null + return taken.map { entry -> + AttachmentMetadata( + id = entry.ui.id, + filename = entry.ui.filename, + mimeType = entry.ui.mimeType, + size = entry.ui.sizeBytes, + path = entry.path!!, + previewUrl = entry.ui.previewBytes?.let { AttachmentPolicy.dataUrl("image/jpeg", it) }, + ) + } + } + + /** + * Leaving the chat for good (ViewModel holder `onCleared`): un-sent + * uploads are orphans on the hub — delete them best-effort on a detached + * scope, because the owning scope is being cancelled right after (same + * pattern as the draft flush). Attachments are NOT part of drafts v1. + */ + @OptIn(DelicateCoroutinesApi::class) + fun discardAllDetached() { + var dropped: List = emptyList() + entries.update { list -> + dropped = list + emptyList() + } + val paths = dropped.mapNotNull { it.path } + if (paths.isEmpty()) return + (detachedCleanupScope ?: GlobalScope).launch(Dispatchers.IO) { + paths.forEach { path -> runCatching { api.deleteUpload(sessionId, path) } } + } + } + + private fun upload(id: String, filename: String, mimeType: String, bytes: ByteArray) { + scope.launch { + val base64 = withContext(encodeDispatcher) { Base64.getEncoder().encodeToString(bytes) } + val path = try { + val response = api.uploadFile(sessionId, filename, base64, mimeType) + if (response.success) response.path else null + } catch (cancellation: CancellationException) { + throw cancellation + } catch (_: Exception) { + null + } + + var applied = false + entries.update { list -> + applied = list.any { it.ui.id == id } + if (!applied) list + else list.map { entry -> + when { + entry.ui.id != id -> entry + // Success: drop the payload bytes — only the preview stays. + path != null -> Entry(entry.ui.copy(status = ComposerAttachmentStatus.Ready), path = path) + else -> Entry(entry.ui.copy(status = ComposerAttachmentStatus.Failed), bytes = entry.bytes) + } + } + } + // Removed while uploading: the hub file just became an orphan. + if (!applied && path != null) { + runCatching { api.deleteUpload(sessionId, path) } + } + } + } +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/chat/blocks/AgentEventBlockView.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/blocks/AgentEventBlockView.kt new file mode 100644 index 0000000000..491281bf89 --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/blocks/AgentEventBlockView.kt @@ -0,0 +1,80 @@ +package app.hapi.companion.feature.chat.blocks + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import app.hapi.companion.ui.theme.HapiTheme +import app.hapi.companion.ui.theme.hapi +import app.hapi.protocol.chat.AgentEvent +import app.hapi.protocol.chat.AgentEventBlock +import app.hapi.protocol.chat.getEventPresentation +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +/** + * Compact centered status row for the `'event'` family (ready / limits / + * compaction / switch / errors / turn duration / …). Icon + wording come from + * the shared protocol presentation (`getEventPresentation`, the web + * `chat/presentation.ts` port); unknown event types fall back to its generic + * raw rendering, truncated. + */ +@Composable +fun AgentEventBlockView(block: AgentEventBlock, modifier: Modifier = Modifier) { + val presentation = remember(block) { getEventPresentation(block.event) } + Box(modifier = modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + Text( + text = listOfNotNull(presentation.icon, presentation.text).joinToString(" "), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.hapi.hint, + textAlign = TextAlign.Center, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .widthIn(max = 480.dp) + .padding(horizontal = 24.dp, vertical = 2.dp), + ) + } +} + +private fun previewEvent(vararg pairs: Pair): AgentEvent = + AgentEvent.of(JsonObject(pairs.associate { (k, v) -> k to JsonPrimitive(v) })) + +@Preview(showBackground = true) +@Composable +private fun AgentEventBlockPreview() { + HapiTheme { + Surface { + Column(modifier = Modifier.padding(vertical = 8.dp)) { + listOf( + previewEvent("type" to "switch", "mode" to "remote"), + previewEvent("type" to "compact"), + previewEvent("type" to "error", "message" to "Agent process exited unexpectedly"), + previewEvent("type" to "title-changed", "title" to "Pagination fix"), + ).forEachIndexed { index, event -> + AgentEventBlockView( + AgentEventBlock( + id = "e$index", + createdAt = 0, + invokedAt = null, + event = event, + meta = null, + ), + ) + } + } + } + } +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/chat/blocks/AgentTextBlockView.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/blocks/AgentTextBlockView.kt new file mode 100644 index 0000000000..778776b3f4 --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/blocks/AgentTextBlockView.kt @@ -0,0 +1,106 @@ +package app.hapi.companion.feature.chat.blocks + +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import app.hapi.companion.R +import app.hapi.companion.ui.markdown.Markdown +import app.hapi.companion.ui.theme.HapiTheme +import app.hapi.companion.ui.theme.hapi +import app.hapi.protocol.chat.AgentReasoningBlock +import app.hapi.protocol.chat.AgentTextBlock + +/** Assistant prose: full-width markdown (the shared M2d1 renderer). */ +@Composable +fun AgentTextBlockView(block: AgentTextBlock, modifier: Modifier = Modifier) { + Markdown(text = block.text, modifier = modifier.fillMaxWidth()) +} + +/** + * Extended thinking: collapsed to a subdued one-liner by default, expands in + * place to the full reasoning markdown (still subdued — it is meta-content). + */ +@Composable +fun AgentReasoningBlockView(block: AgentReasoningBlock, modifier: Modifier = Modifier) { + var expanded by rememberSaveable(block.id) { mutableStateOf(false) } + val hint = MaterialTheme.hapi.hint + + Column( + modifier = modifier + .fillMaxWidth() + .animateContentSize(), + ) { + Row( + modifier = Modifier + .clickable { expanded = !expanded } + .padding(vertical = 2.dp), + ) { + Text( + text = stringResource( + if (expanded) R.string.chat_reasoning_expanded else R.string.chat_reasoning_collapsed, + ), + style = MaterialTheme.typography.labelMedium, + color = hint, + ) + } + if (expanded) { + CompositionLocalProvider(LocalContentColor provides hint) { + Markdown( + text = block.text, + modifier = Modifier.padding(start = 8.dp, top = 4.dp), + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun AgentTextBlockPreview() { + HapiTheme { + Surface { + Column(modifier = Modifier.padding(12.dp)) { + AgentTextBlockView( + AgentTextBlock( + id = "a1", + localId = null, + createdAt = 0, + invokedAt = null, + text = "The failing test was a **cursor regression**:\n\n" + + "1. `beforeSeq` lost its `beforeAt` half\n" + + "2. the hub returned `reset: true`\n\n" + + "```kotlin\nval cursor = MessagePosition(at, seq)\n```", + meta = null, + ), + ) + AgentReasoningBlockView( + AgentReasoningBlock( + id = "r1", + localId = null, + createdAt = 0, + invokedAt = null, + text = "The user wants pagination fixed. Let me check the cursor pair first…", + meta = null, + ), + ) + } + } + } +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/chat/blocks/CliOutputBlockView.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/blocks/CliOutputBlockView.kt new file mode 100644 index 0000000000..fa07197764 --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/blocks/CliOutputBlockView.kt @@ -0,0 +1,75 @@ +package app.hapi.companion.feature.chat.blocks + +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import app.hapi.companion.ui.theme.HapiTheme +import app.hapi.companion.ui.theme.hapi +import app.hapi.protocol.chat.CliOutputBlock + +/** + * `` / slash-command echo: terminal-styled monospace + * panel (no header chrome — this is transcript, not a code sample). + */ +@Composable +fun CliOutputBlockView(block: CliOutputBlock, modifier: Modifier = Modifier) { + TerminalText(text = block.text, modifier = modifier) +} + +/** Shared terminal-look text panel (cli output + tool stdout). */ +@Composable +internal fun TerminalText(text: String, modifier: Modifier = Modifier, isError: Boolean = false) { + val colors = MaterialTheme.hapi + Box( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .background(colors.codeBackground) + .horizontalScroll(rememberScrollState()), + ) { + Text( + text = text, + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + lineHeight = 17.sp, + softWrap = false, + color = if (isError) MaterialTheme.colorScheme.error else colors.inlineCodeForeground, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun CliOutputBlockPreview() { + HapiTheme { + Surface { + CliOutputBlockView( + CliOutputBlock( + id = "c1", + localId = null, + createdAt = 0, + invokedAt = null, + text = "$ bun test\n✓ message-window-store (48 tests)\n1 file, 0 failures", + source = "user", + meta = null, + ), + modifier = Modifier.padding(12.dp), + ) + } + } +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/chat/blocks/CodexReviewBlockView.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/blocks/CodexReviewBlockView.kt new file mode 100644 index 0000000000..0b85073a6e --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/blocks/CodexReviewBlockView.kt @@ -0,0 +1,234 @@ +package app.hapi.companion.feature.chat.blocks + +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import app.hapi.companion.R +import app.hapi.companion.ui.markdown.Markdown +import app.hapi.companion.ui.theme.HapiTheme +import app.hapi.companion.ui.theme.hapi +import app.hapi.protocol.chat.CodexReview +import app.hapi.protocol.chat.CodexReviewBlock +import app.hapi.protocol.chat.CodexReviewFinding +import kotlin.math.roundToInt + +/** + * Codex `/review` verdict card (web `CodexReviewCard`): header with the + * overall-correctness badge (+ confidence), the explanation as markdown, and + * the findings list collapsed behind a count row. + */ +@Composable +fun CodexReviewBlockView(block: CodexReviewBlock, modifier: Modifier = Modifier) { + val review = block.review + val colors = MaterialTheme.hapi + var findingsOpen by rememberSaveable(block.id) { mutableStateOf(false) } + + Surface( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = modifier.fillMaxWidth().animateContentSize(), + ) { + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.chat_review_title), + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.weight(1f), + ) + review.overallCorrectness?.let { verdict -> + VerdictBadge(verdict) + } + formatPercent(review.overallConfidenceScore)?.let { confidence -> + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = confidence, + style = MaterialTheme.typography.labelSmall, + color = colors.hint, + ) + } + } + review.overallExplanation?.takeIf { it.isNotBlank() }?.let { explanation -> + HorizontalDivider(color = colors.divider) + Markdown( + text = explanation, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + ) + } + if (review.findings.isNotEmpty()) { + HorizontalDivider(color = colors.divider) + val findingsLabel = if (review.findings.size == 1) { + stringResource(R.string.chat_review_findings_one) + } else { + stringResource(R.string.chat_review_findings_many, review.findings.size) + } + Text( + text = (if (findingsOpen) "▾ " else "▸ ") + findingsLabel, + style = MaterialTheme.typography.labelLarge, + modifier = Modifier + .fillMaxWidth() + .clickable { findingsOpen = !findingsOpen } + .padding(horizontal = 12.dp, vertical = 8.dp), + ) + if (findingsOpen) { + Column( + modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + review.findings.forEach { FindingRow(it) } + } + } + } + } + } +} + +@Composable +private fun VerdictBadge(verdict: String) { + val (container, content) = when { + verdict.contains("incorrect", ignoreCase = true) -> + MaterialTheme.colorScheme.errorContainer to MaterialTheme.colorScheme.onErrorContainer + verdict.contains("correct", ignoreCase = true) -> + Color(0xFF34C759).copy(alpha = 0.18f) to MaterialTheme.colorScheme.onSurface + else -> + MaterialTheme.colorScheme.surfaceContainerHigh to MaterialTheme.colorScheme.onSurfaceVariant + } + Text( + text = verdict, + style = MaterialTheme.typography.labelSmall, + color = content, + modifier = Modifier + .clip(RoundedCornerShape(6.dp)) + .background(container) + .padding(horizontal = 6.dp, vertical = 2.dp), + ) +} + +@Composable +private fun FindingRow(finding: CodexReviewFinding) { + val colors = MaterialTheme.hapi + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surface, + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.padding(10.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + finding.priority?.let { priority -> + val p = priority.roundToInt() + Text( + text = "P$p", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.SemiBold, + color = if (p <= 1) MaterialTheme.colorScheme.onErrorContainer + else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .background( + if (p <= 1) MaterialTheme.colorScheme.errorContainer + else MaterialTheme.colorScheme.surfaceContainerHigh, + ) + .padding(horizontal = 4.dp, vertical = 1.dp), + ) + Spacer(modifier = Modifier.width(6.dp)) + } + Text( + text = finding.title, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + ) + } + Text( + text = finding.body, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(top = 4.dp), + ) + formatLocation(finding)?.let { location -> + Text( + text = location, + style = MaterialTheme.typography.labelSmall.copy(fontFamily = FontFamily.Monospace, fontSize = 10.sp), + color = colors.hint, + modifier = Modifier.padding(top = 4.dp), + ) + } + } + } +} + +private fun formatPercent(value: Double?): String? { + if (value == null || !value.isFinite()) return null + return "${(value * 100).roundToInt()}%" +} + +private fun formatLocation(finding: CodexReviewFinding): String? { + val filePath = finding.filePath ?: return null + val start = finding.lineStart?.roundToInt() ?: return filePath + val end = finding.lineEnd?.roundToInt() + return if (end != null && end != start) "$filePath:$start-$end" else "$filePath:$start" +} + +@Preview(showBackground = true) +@Composable +private fun CodexReviewBlockPreview() { + HapiTheme { + Surface { + CodexReviewBlockView( + CodexReviewBlock( + id = "cr1", + localId = null, + createdAt = 0, + invokedAt = null, + review = CodexReview( + findings = listOf( + CodexReviewFinding( + title = "Cursor pair can split", + body = "beforeSeq is sent without beforeAt when the snapshot is stale.", + priority = 1.0, + confidenceScore = 0.9, + filePath = "web/src/lib/message-window-store.ts", + lineStart = 210.0, + lineEnd = 218.0, + ), + ), + overallCorrectness = "patch is incorrect", + overallExplanation = "One blocking issue in the pagination cursor handling.", + overallConfidenceScore = 0.82, + ), + meta = null, + ), + modifier = Modifier.padding(12.dp), + ) + } + } +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/chat/blocks/GeneratedImageBlockView.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/blocks/GeneratedImageBlockView.kt new file mode 100644 index 0000000000..26e9c72480 --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/blocks/GeneratedImageBlockView.kt @@ -0,0 +1,87 @@ +package app.hapi.companion.feature.chat.blocks + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import app.hapi.companion.feature.chat.LocalChatMedia +import app.hapi.companion.ui.theme.hapi +import app.hapi.protocol.chat.GeneratedImageBlock +import coil.compose.AsyncImage + +/** + * Hub-generated image (`/api/sessions/:id/generated-images/:imageId`), loaded + * through the per-hub authed Coil loader ([LocalChatMedia]). Tap opens a + * simple full-screen viewer dialog. Without a loader (previews/tests) the + * card degrades to a filename placeholder. + */ +@Composable +fun GeneratedImageBlockView(block: GeneratedImageBlock, modifier: Modifier = Modifier) { + val media = LocalChatMedia.current + val url = remember(block.imageId) { media.generatedImageUrl(block.imageId) } + var viewerOpen by remember { mutableStateOf(false) } + + if (media.imageLoader == null || url == null) { + Text( + text = "🖼 ${block.fileName}", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.hapi.hint, + modifier = modifier.padding(vertical = 4.dp), + ) + return + } + + AsyncImage( + model = url, + imageLoader = media.imageLoader, + contentDescription = block.fileName, + contentScale = ContentScale.Fit, + modifier = modifier + .fillMaxWidth() + .heightIn(max = 360.dp) + .clip(RoundedCornerShape(12.dp)) + .clickable { viewerOpen = true }, + ) + + if (viewerOpen) { + Dialog( + onDismissRequest = { viewerOpen = false }, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.92f)) + .clickable { viewerOpen = false }, + contentAlignment = Alignment.Center, + ) { + AsyncImage( + model = url, + imageLoader = media.imageLoader, + contentDescription = block.fileName, + contentScale = ContentScale.Fit, + modifier = Modifier.fillMaxSize().padding(8.dp), + ) + } + } + } +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/chat/blocks/PermissionActionViews.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/blocks/PermissionActionViews.kt new file mode 100644 index 0000000000..0a2fd66a32 --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/blocks/PermissionActionViews.kt @@ -0,0 +1,582 @@ +package app.hapi.companion.feature.chat.blocks + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import app.hapi.companion.R +import app.hapi.companion.feature.chat.PermissionAction +import app.hapi.companion.feature.chat.PermissionRowOverride +import app.hapi.companion.feature.chat.permissions.AskQuestion +import app.hapi.companion.feature.chat.permissions.isCursorAskQuestionToolName +import app.hapi.companion.feature.chat.permissions.isRequestUserInputAnswered +import app.hapi.companion.feature.chat.permissions.parseAskUserQuestions +import app.hapi.companion.feature.chat.permissions.parseRequestUserInputQuestions +import app.hapi.companion.feature.chat.permissions.requestUserInputAnswerValues +import app.hapi.companion.ui.theme.HapiTheme +import app.hapi.companion.ui.theme.hapi +import app.hapi.protocol.chat.ChatToolCall +import app.hapi.protocol.chat.isAskUserQuestionToolName +import app.hapi.protocol.chat.isRequestUserInputToolName +import app.hapi.protocol.catalog.Flavors + +/** + * Pending-permission footers (B-M3b): the approval buttons for ordinary tool + * permissions plus the dedicated AskUserQuestion / request_user_input answer + * forms. Flavor logic mirrors `PermissionFooter.tsx`: + * + * - codex family (incl. cursor and codex-dialect tool names): Allow + * (`decision: approved`) / Abort (`decision: abort`) + overflow + * Allow-for-session (`decision: approved_for_session`); + * - everyone else: Allow (`{}`) / Deny (`{}`) + overflow Allow-for-session + * (claude: `allowTools`) and, for claude edit tools, Allow-all-edits + * (`mode: acceptEdits`). + */ +@Composable +fun PendingPermissionFooter( + tool: ChatToolCall, + requestId: String, + flavor: String?, + override: PermissionRowOverride?, + onAction: (String, PermissionAction) -> Unit, + modifier: Modifier = Modifier, +) { + when { + isAskUserQuestionToolName(tool.name) -> AskUserQuestionFooter( + tool = tool, + requestId = requestId, + override = override, + onAction = onAction, + modifier = modifier, + ) + isRequestUserInputToolName(tool.name) -> RequestUserInputFooter( + tool = tool, + requestId = requestId, + override = override, + onAction = onAction, + modifier = modifier, + ) + else -> PermissionActionsRow( + tool = tool, + requestId = requestId, + flavor = flavor, + override = override, + onAction = onAction, + modifier = modifier, + ) + } +} + +/** `PermissionFooter.isCodexSession` twin (UI button-set selection). */ +private fun isCodexUx(flavor: String?, toolName: String): Boolean = + Flavors.isCodexFamily(flavor) || flavor == "cursor" || + toolName.startsWith("Codex") || toolName.startsWith("Gemini") || + toolName.startsWith("OpenCode") || toolName.startsWith("Copilot") || + toolName.startsWith("Cursor") + +private val EDIT_TOOLS = setOf("Edit", "MultiEdit", "Write", "NotebookEdit") + +private val HIDE_ALLOW_FOR_SESSION = EDIT_TOOLS + + setOf("exit_plan_mode", "ExitPlanMode", "CursorCreatePlan") + +@Composable +private fun PermissionActionsRow( + tool: ChatToolCall, + requestId: String, + flavor: String?, + override: PermissionRowOverride?, + onAction: (String, PermissionAction) -> Unit, + modifier: Modifier = Modifier, +) { + if (override == PermissionRowOverride.AlreadyHandled) { + AlreadyHandledLine(modifier) + return + } + val resolving = override == PermissionRowOverride.Resolving + val codex = isCodexUx(flavor, tool.name) + val canAllowForSession = !codex && tool.name !in HIDE_ALLOW_FOR_SESSION + val canAllowAllEdits = flavor == "claude" && tool.name in EDIT_TOOLS + var overflowOpen by remember { mutableStateOf(false) } + + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 10.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedButton( + onClick = { onAction(requestId, PermissionAction.Allow) }, + enabled = !resolving, + modifier = Modifier.weight(1f), + ) { + Text(stringResource(R.string.chat_perm_allow), color = MaterialTheme.colorScheme.primary) + } + OutlinedButton( + onClick = { + onAction(requestId, if (codex) PermissionAction.Abort else PermissionAction.Deny) + }, + enabled = !resolving, + modifier = Modifier.weight(1f), + ) { + Text( + stringResource(if (codex) R.string.chat_perm_abort else R.string.chat_perm_deny), + color = MaterialTheme.colorScheme.error, + ) + } + if (resolving) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) + } else if (codex || canAllowForSession || canAllowAllEdits) { + Box { + TextButton(onClick = { overflowOpen = true }) { Text("⋯") } + DropdownMenu(expanded = overflowOpen, onDismissRequest = { overflowOpen = false }) { + if (codex || canAllowForSession) { + DropdownMenuItem( + text = { Text(stringResource(R.string.chat_perm_allow_for_session)) }, + onClick = { + overflowOpen = false + onAction(requestId, PermissionAction.AllowForSession) + }, + ) + } + if (canAllowAllEdits) { + DropdownMenuItem( + text = { Text(stringResource(R.string.chat_perm_allow_all_edits)) }, + onClick = { + overflowOpen = false + onAction(requestId, PermissionAction.AllowAllEdits) + }, + ) + } + } + } + } + } +} + +@Composable +private fun AlreadyHandledLine(modifier: Modifier = Modifier) { + Text( + text = stringResource(R.string.chat_perm_already_handled), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.hapi.hint, + modifier = modifier.padding(horizontal = 10.dp, vertical = 6.dp), + ) +} + +// ------------------------------------------------------- AskUserQuestion -- + +/** + * AskUserQuestion answer form: every question as a card section — options as + * tappable rows (radio/checkbox per `multiSelect`), an "Other" free-text + * choice, one Submit. Answers post flat `{key: [labels…]}` where the key is + * the index (or the Cursor stable id) — `AskUserQuestionFooter.tsx` parity. + */ +@Composable +private fun AskUserQuestionFooter( + tool: ChatToolCall, + requestId: String, + override: PermissionRowOverride?, + onAction: (String, PermissionAction) -> Unit, + modifier: Modifier = Modifier, +) { + if (override == PermissionRowOverride.AlreadyHandled) { + AlreadyHandledLine(modifier) + return + } + val resolving = override == PermissionRowOverride.Resolving + val cursorDialect = isCursorAskQuestionToolName(tool.name) + val questions = remember(tool.id, tool.input) { parseAskUserQuestions(tool.input, cursorDialect) } + + // Selection state per question index. + val selected = remember(tool.id) { mutableStateOf(mapOf>()) } + val otherText = remember(tool.id) { mutableStateOf(mapOf()) } + var validationError by remember(tool.id) { mutableStateOf(null) } + + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 10.dp, vertical = 6.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + if (questions.isEmpty()) { + // Fallback: free-text answer keyed "0" (web parity). + OutlinedTextField( + value = otherText.value[0].orEmpty(), + onValueChange = { otherText.value = otherText.value + (0 to it) }, + enabled = !resolving, + placeholder = { Text(stringResource(R.string.chat_perm_type_answer)) }, + minLines = 2, + modifier = Modifier.fillMaxWidth(), + ) + } else { + questions.forEachIndexed { index, question -> + AskQuestionSection( + question = question, + selectedIndices = selected.value[index] ?: emptySet(), + otherText = otherText.value[index].orEmpty(), + enabled = !resolving, + onToggleOption = { optionIndex -> + val current = selected.value[index] ?: emptySet() + val next = when { + question.multiSelect -> + if (optionIndex in current) current - optionIndex else current + optionIndex + else -> setOf(optionIndex) + } + selected.value = selected.value + (index to next) + validationError = null + }, + onOtherText = { text -> + otherText.value = otherText.value + (index to text) + if (!question.multiSelect && text.isNotBlank()) { + selected.value = selected.value + (index to emptySet()) + } + validationError = null + }, + ) + } + } + + validationError?.let { error -> + Text( + text = error, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.error, + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + if (resolving) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) + } else { + val typeAnswerFirst = stringResource(R.string.chat_perm_type_answer_first) + val answerEveryQuestion = stringResource(R.string.chat_perm_answer_all) + TextButton(onClick = { + val answers = linkedMapOf>() + if (questions.isEmpty()) { + val text = otherText.value[0].orEmpty().trim() + if (text.isEmpty()) { + validationError = typeAnswerFirst + return@TextButton + } + answers["0"] = listOf(text) + } else { + questions.forEachIndexed { index, question -> + val values = mutableListOf() + (selected.value[index] ?: emptySet()).sorted().forEach { optionIndex -> + question.options.getOrNull(optionIndex)?.let { option -> + values += if (cursorDialect) { + option.id?.takeIf { it.isNotBlank() } ?: option.label + } else { + option.label + } + } + } + otherText.value[index]?.trim()?.takeIf { it.isNotEmpty() }?.let { values += it } + if (values.isEmpty()) { + validationError = answerEveryQuestion + return@TextButton + } + answers[question.answerKey(index, cursorDialect)] = values + } + } + onAction(requestId, PermissionAction.FlatAnswers(answers)) + }) { + Text(stringResource(R.string.chat_perm_submit)) + } + } + } + } +} + +@Composable +private fun AskQuestionSection( + question: AskQuestion, + selectedIndices: Set, + otherText: String, + enabled: Boolean, + onToggleOption: (Int) -> Unit, + onOtherText: (String) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + question.header?.let { header -> + Text( + text = header, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.hapi.hint, + ) + } + if (question.question.isNotEmpty()) { + Text(text = question.question, style = MaterialTheme.typography.bodyMedium) + } + question.options.forEachIndexed { optionIndex, option -> + val checked = optionIndex in selectedIndices + Surface( + shape = RoundedCornerShape(10.dp), + color = if (checked) { + MaterialTheme.colorScheme.secondaryContainer + } else { + MaterialTheme.colorScheme.surfaceContainerHigh + }, + modifier = Modifier + .fillMaxWidth() + .selectable( + selected = checked, + enabled = enabled, + onClick = { onToggleOption(optionIndex) }, + ), + ) { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (question.multiSelect) { + Checkbox(checked = checked, onCheckedChange = null, enabled = enabled) + } else { + RadioButton(selected = checked, onClick = null, enabled = enabled) + } + Column(modifier = Modifier.padding(start = 6.dp)) { + Text(text = option.label, style = MaterialTheme.typography.bodyMedium) + option.description?.let { description -> + Text( + text = description, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.hapi.hint, + ) + } + } + } + } + } + OutlinedTextField( + value = otherText, + onValueChange = onOtherText, + enabled = enabled, + placeholder = { Text(stringResource(R.string.chat_perm_other)) }, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +// ----------------------------------------------------- request_user_input -- + +/** + * request_user_input answer form: per-field option rows plus a free-text + * note; answers post nested `{fieldId: {answers: [labels…, "user_note: …"]}}` + * (`RequestUserInputFooter.tsx` parity; the web-only URL confirmation flow is + * not ported). + */ +@Composable +private fun RequestUserInputFooter( + tool: ChatToolCall, + requestId: String, + override: PermissionRowOverride?, + onAction: (String, PermissionAction) -> Unit, + modifier: Modifier = Modifier, +) { + if (override == PermissionRowOverride.AlreadyHandled) { + AlreadyHandledLine(modifier) + return + } + val resolving = override == PermissionRowOverride.Resolving + val questions = remember(tool.id, tool.input) { parseRequestUserInputQuestions(tool.input) } + + val selected = remember(tool.id) { mutableStateOf(mapOf>()) } + val notes = remember(tool.id) { + mutableStateOf(questions.associate { it.id to it.prefill.orEmpty() }) + } + var validationError by remember(tool.id) { mutableStateOf(null) } + + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 10.dp, vertical = 6.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + questions.forEach { question -> + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + if (question.question.isNotEmpty()) { + Text( + text = if (question.required) { + question.question + } else { + stringResource(R.string.chat_perm_optional_format, question.question) + }, + style = MaterialTheme.typography.bodyMedium, + ) + } + question.options.forEach { option -> + val checked = option.label in (selected.value[question.id] ?: emptySet()) + Surface( + shape = RoundedCornerShape(10.dp), + color = if (checked) { + MaterialTheme.colorScheme.secondaryContainer + } else { + MaterialTheme.colorScheme.surfaceContainerHigh + }, + modifier = Modifier + .fillMaxWidth() + .selectable(selected = checked, enabled = !resolving, onClick = { + val current = selected.value[question.id] ?: emptySet() + val next = when { + question.multiple -> + if (option.label in current) current - option.label + else current + option.label + else -> setOf(option.label) + } + selected.value = selected.value + (question.id to next) + validationError = null + }), + ) { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (question.multiple) { + Checkbox(checked = checked, onCheckedChange = null, enabled = !resolving) + } else { + RadioButton(selected = checked, onClick = null, enabled = !resolving) + } + Column(modifier = Modifier.padding(start = 6.dp)) { + Text(text = option.label, style = MaterialTheme.typography.bodyMedium) + option.description?.let { description -> + Text( + text = description, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.hapi.hint, + ) + } + } + } + } + } + OutlinedTextField( + value = notes.value[question.id].orEmpty(), + onValueChange = { + notes.value = notes.value + (question.id to it) + validationError = null + }, + enabled = !resolving, + placeholder = { Text(question.placeholder ?: stringResource(R.string.chat_perm_add_note)) }, + modifier = Modifier.fillMaxWidth(), + ) + } + } + + validationError?.let { error -> + Text( + text = error, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.error, + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + if (resolving) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) + } else { + val answerEveryRequired = stringResource(R.string.chat_perm_answer_required) + TextButton(onClick = { + for (question in questions) { + val questionSelected = (selected.value[question.id] ?: emptySet()).toList() + val note = notes.value[question.id].orEmpty() + if (!isRequestUserInputAnswered(question, questionSelected, note)) { + validationError = answerEveryRequired + return@TextButton + } + } + val answers = linkedMapOf>() + questions.forEach { question -> + answers[question.id] = requestUserInputAnswerValues( + selected = (selected.value[question.id] ?: emptySet()).toList(), + note = notes.value[question.id].orEmpty(), + ) + } + onAction(requestId, PermissionAction.NestedAnswers(answers)) + }) { + Text(stringResource(R.string.chat_perm_submit)) + } + } + } + } +} + +// -------------------------------------------------------------- previews -- + +@Preview(showBackground = true) +@Composable +private fun PermissionActionsPreview() { + HapiTheme { + Surface { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + PermissionActionsRow( + tool = previewToolCall("p1", "Bash", input = mapOf("command" to "rm -rf build")).tool, + requestId = "p1", + flavor = "claude", + override = null, + onAction = { _, _ -> }, + ) + PermissionActionsRow( + tool = previewToolCall("p2", "Edit", input = mapOf("file_path" to "/a/b.kt")).tool, + requestId = "p2", + flavor = "claude", + override = PermissionRowOverride.Resolving, + onAction = { _, _ -> }, + ) + PermissionActionsRow( + tool = previewToolCall("p3", "CodexBash", input = mapOf("command" to "ls")).tool, + requestId = "p3", + flavor = "codex", + override = null, + onAction = { _, _ -> }, + ) + PermissionActionsRow( + tool = previewToolCall("p4", "Bash").tool, + requestId = "p4", + flavor = "claude", + override = PermissionRowOverride.AlreadyHandled, + onAction = { _, _ -> }, + ) + } + } + } +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/chat/blocks/ToolBodies.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/blocks/ToolBodies.kt new file mode 100644 index 0000000000..cf799bdede --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/blocks/ToolBodies.kt @@ -0,0 +1,341 @@ +package app.hapi.companion.feature.chat.blocks + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import app.hapi.companion.R +import app.hapi.companion.feature.chat.displayPath +import app.hapi.companion.feature.chat.terminalCommand +import app.hapi.companion.ui.components.DiffView +import app.hapi.companion.ui.markdown.CodeBlock +import app.hapi.companion.ui.theme.hapi +import app.hapi.protocol.chat.ChatToolCall +import app.hapi.protocol.chat.getInputString +import app.hapi.protocol.chat.getInputStringAny +import app.hapi.protocol.chat.isAskUserQuestionToolName +import app.hapi.protocol.chat.isRequestUserInputToolName +import app.hapi.protocol.git.DiffFile +import app.hapi.protocol.git.UnifiedDiffParser +import app.hapi.protocol.wire.HapiJson +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +/** + * Expanded tool-card body: input rendering per tool kind + the result section + * (the read-only slice of `web/src/components/ToolCard/views/`): + * + * - terminal family → command as a bash code block, stdout/stderr terminal-styled; + * - `Edit`/`MultiEdit` structured edits → before/after code blocks (the web + * derives a word diff from `old_string`/`new_string`; ported minimally); + * - `Write` → the written content as a code block; + * - `CodexDiff` (and any input/result that parses as a unified diff) → [DiffView]; + * - `TodoWrite`/`update_plan` → checklist rows; + * - Ask/RequestUserInput → questions + options, read-only; + * - anything else → pretty-printed JSON input, then the generic result. + */ +@Composable +internal fun ToolCallBody(tool: ChatToolCall, basePath: String?, modifier: Modifier = Modifier) { + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) { + ToolInputSection(tool, basePath) + ToolResultSection(tool) + } +} + +// ------------------------------------------------------------------ input -- + +@Composable +private fun ToolInputSection(tool: ChatToolCall, basePath: String?) { + val input = tool.input + when { + tool.name in TERMINAL_TOOLS -> { + terminalCommand(input)?.let { command -> + CodeBlock(code = command, language = "bash") + } + } + + tool.name == "Edit" -> { + val old = getInputString(input, "old_string") + val new = getInputString(input, "new_string") + if (old != null && new != null) { + BeforeAfter(old, new, languageForPath(getInputStringAny(input, listOf("file_path", "path")))) + } else { + GenericJsonInput(input) + } + } + + tool.name == "MultiEdit" -> { + val language = languageForPath(getInputStringAny(input, listOf("file_path", "path"))) + val edits = (input as? JsonObject)?.get("edits") as? JsonArray + if (edits != null) { + edits.forEachIndexed { index, edit -> + val old = getInputString(edit, "old_string") + val new = getInputString(edit, "new_string") + if (old != null && new != null) { + if (edits.size > 1) { + SectionLabel(stringResource(R.string.chat_edit_n_of_m, index + 1, edits.size)) + } + BeforeAfter(old, new, language) + } + } + } else { + GenericJsonInput(input) + } + } + + tool.name == "Write" -> { + val content = getInputStringAny(input, listOf("content", "text")) + if (content != null) { + CodeBlock( + code = content, + language = languageForPath(getInputStringAny(input, listOf("file_path", "path"))), + ) + } else { + GenericJsonInput(input) + } + } + + tool.name == "CodexDiff" -> { + val unified = getInputString(input, "unified_diff") + val files = unified?.let(::tryParseDiff) + if (files != null) { + files.forEach { DiffView(file = it) } + } else { + GenericJsonInput(input) + } + } + + tool.name == "TodoWrite" || tool.name == "update_plan" -> { + val items = checklistItems(input) + if (items.isNotEmpty()) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + items.forEach { (state, text) -> + Text( + text = "$state $text", + style = MaterialTheme.typography.bodySmall, + ) + } + } + } else { + GenericJsonInput(input) + } + } + + isAskUserQuestionToolName(tool.name) || isRequestUserInputToolName(tool.name) -> { + QuestionsReadOnly(input) + } + + tool.name == "Read" || tool.name == "NotebookRead" || tool.name == "LS" -> { + // The title already carries the path; nothing else worth echoing. + getInputStringAny(input, listOf("file_path", "path", "notebook_path"))?.let { path -> + SectionLabel(displayPath(path, basePath)) + } + } + + else -> GenericJsonInput(input) + } +} + +@Composable +private fun BeforeAfter(old: String, new: String, language: String?) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + val emptyLabel = stringResource(R.string.chat_empty_snippet) + SectionLabel(stringResource(R.string.chat_before)) + CodeBlock(code = old.ifEmpty { emptyLabel }, language = language) + SectionLabel(stringResource(R.string.chat_after)) + CodeBlock(code = new.ifEmpty { emptyLabel }, language = language) + } +} + +@Composable +private fun GenericJsonInput(input: JsonElement?) { + when { + input == null || input is JsonNull -> Unit + input is JsonPrimitive && input.isString -> CodeBlock(code = input.content, language = null) + else -> CodeBlock(code = prettyJson(input), language = "json") + } +} + +@Composable +private fun QuestionsReadOnly(input: JsonElement?) { + val questions = (input as? JsonObject)?.get("questions") as? JsonArray ?: return + val hint = MaterialTheme.hapi.hint + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + questions.forEach { entry -> + val question = entry as? JsonObject ?: return@forEach + val header = (question["header"] as? JsonPrimitive)?.contentOrNullIfNotString() + val text = (question["question"] as? JsonPrimitive)?.contentOrNullIfNotString() + Column { + header?.let { + Text(text = it, style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold) + } + text?.let { + Text(text = it, style = MaterialTheme.typography.bodyMedium) + } + val options = question["options"] as? JsonArray + options?.forEach { option -> + val label = when (option) { + is JsonPrimitive -> option.contentOrNullIfNotString() + is JsonObject -> (option["label"] as? JsonPrimitive)?.contentOrNullIfNotString() + ?: (option["value"] as? JsonPrimitive)?.contentOrNullIfNotString() + else -> null + } + label?.let { + Text( + text = "◦ $it", + style = MaterialTheme.typography.bodySmall, + color = hint, + modifier = Modifier.padding(start = 8.dp, top = 2.dp), + ) + } + } + } + } + } +} + +// ----------------------------------------------------------------- result -- + +/** How a tool result renders: parsed diff > extracted text > pretty JSON. */ +private sealed interface ResultRendering { + data class Diffs(val files: List) : ResultRendering + data class Terminal(val text: String) : ResultRendering + data class Json(val pretty: String) : ResultRendering +} + +@Composable +private fun ToolResultSection(tool: ChatToolCall) { + val result = tool.result ?: return + if (result is JsonNull) return + val isError = tool.state == "error" + val rendering = remember(result) { resultRendering(result) } ?: return + + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + SectionLabel( + stringResource(if (isError) R.string.chat_result_error else R.string.chat_result), + ) + when (rendering) { + is ResultRendering.Diffs -> rendering.files.forEach { DiffView(file = it) } + is ResultRendering.Terminal -> TerminalText(rendering.text, isError = isError) + is ResultRendering.Json -> CodeBlock(code = rendering.pretty, language = "json") + } + } +} + +private const val RESULT_RENDER_CAP = 20_000 + +private fun resultRendering(result: JsonElement): ResultRendering? { + val text = extractResultText(result) + if (text != null) { + if (text.isBlank()) return null + tryParseDiff(text)?.let { return ResultRendering.Diffs(it) } + return ResultRendering.Terminal(text.take(RESULT_RENDER_CAP)) + } + return ResultRendering.Json(prettyJson(result).take(RESULT_RENDER_CAP)) +} + +/** + * Text of the common result shapes: plain string; `{stdout, stderr}`; + * Claude-style `[{type: "text", text}]` arrays (or the same under `content`). + * Null → not text-like, render as JSON. + */ +internal fun extractResultText(result: JsonElement): String? { + when (result) { + is JsonPrimitive -> return if (result.isString) result.content else null + is JsonArray -> { + val texts = result.map { entry -> + val obj = entry as? JsonObject ?: return null + if ((obj["type"] as? JsonPrimitive)?.content != "text") return null + (obj["text"] as? JsonPrimitive)?.takeIf { it.isString }?.content ?: return null + } + return texts.joinToString("\n") + } + is JsonObject -> { + val stdout = (result["stdout"] as? JsonPrimitive)?.takeIf { it.isString }?.content + val stderr = (result["stderr"] as? JsonPrimitive)?.takeIf { it.isString }?.content + if (stdout != null || stderr != null) { + val parts = mutableListOf() + stdout?.trimEnd()?.takeIf { it.isNotEmpty() }?.let(parts::add) + stderr?.trimEnd()?.takeIf { it.isNotEmpty() }?.let { parts.add("stderr:\n$it") } + return parts.joinToString("\n\n") + } + (result["content"] as? JsonArray)?.let { return extractResultText(it) } + (result["content"] as? JsonPrimitive)?.takeIf { it.isString }?.let { return it.content } + return null + } + } +} + +// ---------------------------------------------------------------- helpers -- + +private val TERMINAL_TOOLS = setOf("Bash", "CodexBash", "shell_command", "run_shell_command") + +private val DIFF_MARKER = Regex("(^|\n)@@ -\\d") +private val DIFF_HEADER = Regex("(^|\n)(diff --git |--- )") + +/** Parse [text] as a unified diff when it plausibly is one. */ +internal fun tryParseDiff(text: String): List? { + if (!DIFF_MARKER.containsMatchIn(text) || !DIFF_HEADER.containsMatchIn(text)) return null + val files = UnifiedDiffParser.parse(text) + return files.takeIf { parsed -> parsed.isNotEmpty() && parsed.any { it.hunks.isNotEmpty() || it.isBinary } } +} + +private val prettyJsonFormat = Json(from = HapiJson) { prettyPrint = true } + +internal fun prettyJson(element: JsonElement): String = + prettyJsonFormat.encodeToString(JsonElement.serializer(), element) + +private val EXTENSION_LANGUAGES = mapOf( + "kt" to "kotlin", "kts" to "kotlin", "java" to "java", "ts" to "typescript", + "tsx" to "typescript", "js" to "javascript", "jsx" to "javascript", "py" to "python", + "rb" to "ruby", "go" to "go", "rs" to "rust", "swift" to "swift", "c" to "c", + "h" to "c", "cpp" to "cpp", "cc" to "cpp", "cs" to "csharp", "sh" to "shell", + "bash" to "shell", "json" to "json", "yml" to "yaml", "yaml" to "yaml", + "xml" to "xml", "html" to "html", "css" to "css", "md" to "markdown", "sql" to "sql", +) + +private fun languageForPath(path: String?): String? = + path?.substringAfterLast('.', missingDelimiterValue = "")?.lowercase() + ?.takeIf { it.isNotEmpty() } + ?.let { EXTENSION_LANGUAGES[it] } + +private fun JsonPrimitive.contentOrNullIfNotString(): String? = if (isString) content else null + +/** `(glyph, text)` rows for TodoWrite `todos` / update_plan `plan` items. */ +private fun checklistItems(input: JsonElement?): List> { + val obj = input as? JsonObject ?: return emptyList() + val array = (obj["todos"] as? JsonArray) ?: (obj["plan"] as? JsonArray) ?: return emptyList() + return array.mapNotNull { entry -> + val item = entry as? JsonObject ?: return@mapNotNull null + val content = (item["content"] as? JsonPrimitive)?.contentOrNullIfNotString() + ?: (item["step"] as? JsonPrimitive)?.contentOrNullIfNotString() + ?: return@mapNotNull null + val status = (item["status"] as? JsonPrimitive)?.contentOrNullIfNotString() + val glyph = when (status) { + "completed", "complete", "done" -> "☑" + "in_progress" -> "◐" + else -> "☐" + } + glyph to content + } +} + +@Composable +private fun SectionLabel(text: String) { + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.hapi.hint, + ) +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/chat/blocks/ToolCallBlockView.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/blocks/ToolCallBlockView.kt new file mode 100644 index 0000000000..30cbc8770e --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/blocks/ToolCallBlockView.kt @@ -0,0 +1,330 @@ +package app.hapi.companion.feature.chat.blocks + +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import app.hapi.companion.R +import app.hapi.companion.feature.chat.ChatBlockCard +import app.hapi.companion.feature.chat.LocalChatInteractions +import app.hapi.companion.feature.chat.toolCardPresentation +import app.hapi.companion.ui.theme.HapiTheme +import app.hapi.companion.ui.theme.hapi +import app.hapi.protocol.chat.ChatToolCall +import app.hapi.protocol.chat.ToolCallBlock +import app.hapi.protocol.chat.ToolPermission +import app.hapi.protocol.chat.ToolState +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +/** + * One tool invocation (web `ToolCard`): collapsed header row — icon glyph, + * title, subtitle, status — expanding to the per-tool body ([ToolCallBody]), + * the read-only permission state, and nested children (sidechain transcript). + * Cards with a pending permission start expanded and carry the + * "awaiting approval" banner (actions land in M3b). + */ +@Composable +fun ToolCallBlockView(block: ToolCallBlock, basePath: String?, modifier: Modifier = Modifier) { + val tool = block.tool + val resources = LocalContext.current.resources + val presentation = remember(tool, basePath, resources) { toolCardPresentation(tool, basePath, resources) } + val pendingPermission = tool.permission?.status == "pending" + var expanded by rememberSaveable(block.id) { mutableStateOf(pendingPermission) } + val colors = MaterialTheme.hapi + + Surface( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = modifier.fillMaxWidth().animateContentSize(), + ) { + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { expanded = !expanded } + .padding(horizontal = 10.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(text = presentation.icon, fontSize = 14.sp) + Spacer(modifier = Modifier.width(8.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = presentation.title, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + presentation.subtitle?.let { subtitle -> + Text( + text = subtitle, + style = MaterialTheme.typography.labelSmall, + color = colors.hint, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + Spacer(modifier = Modifier.width(8.dp)) + ToolStatusIndicator(tool.state) + } + + tool.permission?.let { permission -> + val interactions = LocalChatInteractions.current + if (permission.status == "pending" && interactions != null) { + Surface( + color = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.35f), + modifier = Modifier.fillMaxWidth(), + ) { + Column { + Text( + text = stringResource(R.string.chat_tool_awaiting_approval), + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(start = 10.dp, top = 6.dp), + ) + PendingPermissionFooter( + tool = tool, + requestId = permission.id, + flavor = interactions.flavor, + override = interactions.permissionOverrides[permission.id], + onAction = interactions.resolvePermission, + ) + } + } + } else { + PermissionStateRow(permission) + } + } + + if (expanded) { + ToolCallBody( + tool = tool, + basePath = basePath, + modifier = Modifier.padding(start = 10.dp, end = 10.dp, bottom = 10.dp), + ) + } + + if (block.children.isNotEmpty()) { + ChildrenColumn(block, basePath, expanded) + } + } + } +} + +/** Sidechain children, nested behind an indent rail; collapsed to a count row. */ +@Composable +private fun ChildrenColumn(block: ToolCallBlock, basePath: String?, parentExpanded: Boolean) { + var childrenOpen by rememberSaveable("children:" + block.id) { mutableStateOf(parentExpanded) } + val colors = MaterialTheme.hapi + + val stepsLabel = if (block.children.size == 1) { + stringResource(R.string.chat_agent_steps_one) + } else { + stringResource(R.string.chat_agent_steps_many, block.children.size) + } + Text( + text = (if (childrenOpen) "▾ " else "▸ ") + stepsLabel, + style = MaterialTheme.typography.labelMedium, + color = colors.hint, + modifier = Modifier + .fillMaxWidth() + .clickable { childrenOpen = !childrenOpen } + .padding(horizontal = 10.dp, vertical = 6.dp), + ) + if (!childrenOpen) return + + Row( + modifier = Modifier + .padding(start = 12.dp, end = 8.dp, bottom = 10.dp) + .height(IntrinsicSize.Min), + ) { + Spacer( + modifier = Modifier + .width(2.dp) + .fillMaxHeight() + .clip(RoundedCornerShape(1.dp)) + .background(colors.divider), + ) + Column( + modifier = Modifier + .weight(1f) + .padding(start = 10.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + block.children.forEach { child -> + ChatBlockCard(block = child, basePath = basePath) + } + } + } +} + +@Composable +internal fun ToolStatusIndicator(state: String) { + when (state) { + ToolState.RUNNING -> CircularProgressIndicator( + modifier = Modifier.size(14.dp), + strokeWidth = 2.dp, + ) + ToolState.PENDING -> StatusChip( + text = stringResource(R.string.chat_tool_status_pending), + container = MaterialTheme.colorScheme.surfaceContainerHigh, + content = MaterialTheme.colorScheme.onSurfaceVariant, + ) + ToolState.ERROR -> StatusChip( + text = stringResource(R.string.chat_tool_status_error), + container = MaterialTheme.colorScheme.errorContainer, + content = MaterialTheme.colorScheme.onErrorContainer, + ) + else -> Text( + text = "✓", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.hapi.hint, + ) + } +} + +@Composable +private fun StatusChip(text: String, container: androidx.compose.ui.graphics.Color, content: androidx.compose.ui.graphics.Color) { + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + color = content, + modifier = Modifier + .clip(RoundedCornerShape(6.dp)) + .background(container) + .padding(horizontal = 6.dp, vertical = 2.dp), + ) +} + +/** + * Read-only permission verdict: highlighted banner while pending (renders + * only without a [LocalChatInteractions] provider — previews/tests; the live + * chat replaces it with [PendingPermissionFooter]), subdued line once decided. + */ +@Composable +private fun PermissionStateRow(permission: ToolPermission) { + when (permission.status) { + "pending" -> Surface( + color = MaterialTheme.colorScheme.tertiaryContainer, + contentColor = MaterialTheme.colorScheme.onTertiaryContainer, + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)) { + Text( + text = stringResource(R.string.chat_tool_awaiting_approval_badge), + style = MaterialTheme.typography.labelLarge, + ) + } + } + "approved" -> PermissionLine( + stringResource(R.string.chat_tool_approved) + (permission.mode?.let { " · $it" } ?: ""), + ) + "denied" -> PermissionLine( + stringResource(R.string.chat_tool_denied) + (permission.reason?.let { " · $it" } ?: ""), + error = true, + ) + "canceled" -> PermissionLine(stringResource(R.string.chat_tool_canceled)) + } +} + +@Composable +private fun PermissionLine(text: String, error: Boolean = false) { + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + color = if (error) MaterialTheme.colorScheme.error else MaterialTheme.hapi.hint, + modifier = Modifier.padding(start = 10.dp, end = 10.dp, bottom = 6.dp), + ) +} + +// -------------------------------------------------------------- previews -- + +internal fun previewToolCall( + id: String, + name: String, + state: String = ToolState.COMPLETED, + input: Map = emptyMap(), + permission: ToolPermission? = null, +): ToolCallBlock = ToolCallBlock( + id = id, + localId = null, + createdAt = 0, + invokedAt = null, + tool = ChatToolCall( + id = id, + name = name, + state = state, + input = JsonObject(input.mapValues { (_, value) -> JsonPrimitive(value) }), + createdAt = 0, + description = null, + permission = permission, + ), + children = emptyList(), + meta = null, +) + +@Preview(showBackground = true) +@Composable +private fun ToolCallBlockPreview() { + HapiTheme { + Surface { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + ToolCallBlockView( + previewToolCall("t1", "Bash", ToolState.RUNNING, mapOf("command" to "bun test --watch")), + basePath = null, + ) + ToolCallBlockView( + previewToolCall("t2", "Read", input = mapOf("file_path" to "/repo/web/src/chat/reducer.ts")), + basePath = "/repo", + ) + ToolCallBlockView( + previewToolCall( + "t3", + "Bash", + ToolState.PENDING, + mapOf("command" to "rm -rf build"), + permission = ToolPermission( + id = "p1", + status = "pending", + presence = setOf("id", "status"), + ), + ), + basePath = null, + ) + } + } + } +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/chat/blocks/ToolGroupBlockView.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/blocks/ToolGroupBlockView.kt new file mode 100644 index 0000000000..cb4dfa6a43 --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/blocks/ToolGroupBlockView.kt @@ -0,0 +1,147 @@ +package app.hapi.companion.feature.chat.blocks + +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import app.hapi.companion.R +import app.hapi.companion.ui.theme.HapiTheme +import app.hapi.companion.ui.theme.hapi +import app.hapi.protocol.chat.ToolGroupBlock +import app.hapi.protocol.chat.ToolGroupSummary + +/** + * Run of adjacent groupable tools (web `ToolGroupCard`): a one-line summary — + * count + first targets + error/running signals — expanding to the individual + * [ToolCallBlockView]s. Codex exploration groups honor their `defaultOpen`. + */ +@Composable +fun ToolGroupBlockView(block: ToolGroupBlock, basePath: String?, modifier: Modifier = Modifier) { + var expanded by rememberSaveable(block.id) { mutableStateOf(block.defaultOpen) } + val colors = MaterialTheme.hapi + val summaryText = remember(block) { groupSummaryText(block) } + + Surface( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = modifier.fillMaxWidth().animateContentSize(), + ) { + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { expanded = !expanded } + .padding(horizontal = 10.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(text = "🔧", fontSize = 14.sp) + Spacer(modifier = Modifier.width(8.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = block.activityTitle + ?: if (block.summary.totalTools == 1) { + stringResource(R.string.chat_group_tools_one) + } else { + stringResource(R.string.chat_group_tools_many, block.summary.totalTools) + }, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (summaryText.isNotEmpty()) { + Text( + text = summaryText, + style = MaterialTheme.typography.labelSmall, + color = colors.hint, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + Spacer(modifier = Modifier.width(8.dp)) + if (block.summary.runningCount > 0) { + CircularProgressIndicator(modifier = Modifier.size(14.dp), strokeWidth = 2.dp) + } else if (block.summary.errorCount > 0) { + Text( + text = "${block.summary.errorCount} ⚠", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.error, + ) + } else { + Text( + text = if (expanded) "▾" else "▸", + style = MaterialTheme.typography.labelMedium, + color = colors.hint, + ) + } + } + if (expanded) { + Column( + modifier = Modifier.padding(start = 10.dp, end = 10.dp, bottom = 10.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + block.tools.forEach { tool -> + ToolCallBlockView(block = tool, basePath = basePath) + } + } + } + } + } +} + +/** "file, other-file +2 · 1 command" style digest from the group summary. */ +private fun groupSummaryText(block: ToolGroupBlock): String { + val summary: ToolGroupSummary = block.summary + val targets = (summary.fileTargets + summary.searchTargets + summary.commandTargets + + summary.urlTargets + summary.otherTargets) + if (targets.isEmpty()) return "" + val shown = targets.take(3).joinToString(", ") { it.substringAfterLast('/').ifEmpty { it } } + val more = targets.size - 3 + return if (more > 0) "$shown +$more" else shown +} + +@Preview(showBackground = true) +@Composable +private fun ToolGroupBlockPreview() { + HapiTheme { + Surface { + val tools = listOf( + previewToolCall("g1", "Read", input = mapOf("file_path" to "web/src/chat/reducer.ts")), + previewToolCall("g2", "Grep", input = mapOf("pattern" to "tailRevision")), + previewToolCall("g3", "Bash", input = mapOf("command" to "bun test")), + ) + ToolGroupBlockView( + app.hapi.protocol.chat.buildVisibleChatBlocks( + tools, + app.hapi.protocol.chat.ToolGroupingOptions(hasMoreMessages = false), + ).filterIsInstance().first(), + basePath = null, + modifier = Modifier.padding(12.dp), + ) + } + } +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/chat/blocks/UserTextBlockView.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/blocks/UserTextBlockView.kt new file mode 100644 index 0000000000..1d6cfe0914 --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/blocks/UserTextBlockView.kt @@ -0,0 +1,193 @@ +package app.hapi.companion.feature.chat.blocks + +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import app.hapi.companion.R +import app.hapi.companion.feature.chat.LocalChatInteractions +import app.hapi.companion.feature.chat.attachments.PreviewImage +import app.hapi.companion.feature.chat.attachments.rememberPreviewImage +import app.hapi.companion.ui.theme.HapiTheme +import app.hapi.protocol.chat.ChatAttachment +import app.hapi.protocol.chat.UserTextBlock + +/** + * Operator prompt: right-aligned bubble (whitespace preserved — prompts are + * not rendered as markdown, matching the web user bubble), attachments as + * image thumbnails (decoded from the wire `previewUrl` data URL — both + * Android- and web-sent messages carry one, and optimistic rows do too, so + * thumbnails appear instantly on send) or filename chips, and a failed-send + * tap-to-retry hint (B-M3f upgrades the former chips-only rendering). + */ +@Composable +fun UserTextBlockView(block: UserTextBlock, modifier: Modifier = Modifier) { + val maxBubbleWidth = (LocalConfiguration.current.screenWidthDp * 0.85f).dp + Row(modifier = modifier.fillMaxWidth()) { + Spacer(modifier = Modifier.width(48.dp).weight(1f)) + Column(horizontalAlignment = Alignment.End) { + Surface( + color = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp, bottomStart = 16.dp, bottomEnd = 4.dp), + modifier = Modifier.widthIn(max = maxBubbleWidth), + ) { + Column(modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp)) { + Text( + text = block.text, + style = MaterialTheme.typography.bodyLarge.copy(fontSize = 15.sp, lineHeight = 21.sp), + ) + block.attachments?.takeIf { it.isNotEmpty() }?.let { attachments -> + Column( + modifier = Modifier.padding(top = 8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + horizontalAlignment = Alignment.End, + ) { + attachments.forEach { AttachmentView(it) } + } + } + } + } + if (block.status == "failed") { + val interactions = LocalChatInteractions.current + val retryLocalId = block.localId + val retryModifier = if (interactions != null && retryLocalId != null) { + Modifier.clickable { interactions.retryFailedMessage(retryLocalId) } + } else { + Modifier + } + Text( + text = stringResource( + if (interactions != null && retryLocalId != null) { + R.string.chat_not_delivered_retry + } else { + R.string.chat_not_delivered + }, + ), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.error, + modifier = retryModifier.padding(top = 2.dp, end = 4.dp), + ) + } + } + } +} + +/** + * One bubble attachment: image mimes with a decodable `previewUrl` render a + * thumbnail (web `MessageAttachments` split); everything else — plus decode + * failures — falls back to the filename chip. + */ +@Composable +private fun AttachmentView(attachment: ChatAttachment) { + val isImage = attachment.mimeType.startsWith("image/") + if (!isImage || attachment.previewUrl == null) { + AttachmentChip(attachment) + return + } + val preview by rememberPreviewImage(attachment.previewUrl) + when (val state = preview) { + is PreviewImage.Ready -> Image( + bitmap = state.bitmap, + contentDescription = attachment.filename, + contentScale = ContentScale.Fit, + alignment = Alignment.CenterEnd, + modifier = Modifier + .heightIn(max = 180.dp) + .clip(RoundedCornerShape(10.dp)), + ) + // Sized placeholder while decoding keeps the bubble from jumping. + PreviewImage.Loading -> Surface( + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.4f), + shape = RoundedCornerShape(10.dp), + ) { + Spacer(modifier = Modifier.size(width = 120.dp, height = 90.dp)) + } + PreviewImage.Unavailable -> AttachmentChip(attachment) + } +} + +@Composable +private fun AttachmentChip(attachment: ChatAttachment) { + Surface( + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.55f), + shape = RoundedCornerShape(8.dp), + ) { + Text( + text = "${if (attachment.mimeType.startsWith("image/")) "🖼" else "📎"} ${attachment.filename}", + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun UserTextBlockPreview() { + HapiTheme { + Surface { + Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + UserTextBlockView( + UserTextBlock( + id = "u1", + localId = null, + createdAt = 0, + invokedAt = null, + text = "Fix the failing pagination test and explain the root cause", + attachments = null, + status = null, + originalText = null, + meta = null, + ), + ) + UserTextBlockView( + UserTextBlock( + id = "u2", + localId = null, + createdAt = 0, + invokedAt = null, + text = "Here is the screenshot", + attachments = listOf( + ChatAttachment( + id = "a1", + filename = "screenshot.png", + mimeType = "image/png", + size = 1024.0, + path = "/uploads/screenshot.png", + ), + ), + status = "failed", + originalText = null, + meta = null, + ), + ) + } + } + } +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/chat/composer/ChatComposer.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/composer/ChatComposer.kt new file mode 100644 index 0000000000..72259c1588 --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/composer/ChatComposer.kt @@ -0,0 +1,653 @@ +package app.hapi.companion.feature.chat.composer + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import app.hapi.companion.R +import app.hapi.companion.feature.chat.ComposerUiState +import app.hapi.companion.feature.chat.attachments.ComposerAttachmentStatus +import app.hapi.companion.feature.chat.attachments.ComposerAttachmentUi +import app.hapi.companion.feature.chat.attachments.rememberChipThumbnail +import app.hapi.companion.ui.theme.HapiTheme +import app.hapi.companion.ui.theme.hapi +import app.hapi.protocol.wire.SlashCommand +import kotlinx.coroutines.delay + +/** + * The chat input bar (B-M3a, extended in B-M3ce/B-M3f): multiline text field + * (Enter = newline, mobile default), a send button whose long-press offers + * "Send & steer" while a turn is active, an abort button during thinking, + * a mic button for press-to-toggle dictation (recording chip with elapsed + * time + cancel while capturing), a slash-command dropdown that opens while + * the text is a lone `/token`, and the attachment tray: a "+" button opening + * the picker sheet plus per-attachment chips (uploading spinner → thumbnail / + * failed tap-to-retry, ✕ removes). + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun ChatComposer( + state: ComposerUiState, + onTextChange: (String) -> Unit, + onSend: () -> Unit, + onSendSteer: () -> Unit, + onAbort: () -> Unit, + modifier: Modifier = Modifier, + attachments: List = emptyList(), + onAddAttachment: (() -> Unit)? = null, + onAttachmentRetry: (String) -> Unit = {}, + onAttachmentRemove: (String) -> Unit = {}, + slashSuggestions: List = emptyList(), + onSlashCommandSelected: (SlashCommand) -> Unit = {}, + /** null ⇒ dictation unavailable (no controller wired) — mic button hidden. */ + dictation: DictationState? = null, + onDictationToggle: () -> Unit = {}, + onDictationCancel: () -> Unit = {}, +) { + Surface(color = MaterialTheme.colorScheme.surface, modifier = modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)) { + if (slashSuggestions.isNotEmpty()) { + SlashCommandDropdown( + suggestions = slashSuggestions, + onSelect = onSlashCommandSelected, + modifier = Modifier.padding(bottom = 6.dp), + ) + } + if (attachments.isNotEmpty()) { + LazyRow( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 6.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + items(attachments, key = { it.id }) { attachment -> + ComposerAttachmentChip( + attachment = attachment, + onRetry = { onAttachmentRetry(attachment.id) }, + onRemove = { onAttachmentRemove(attachment.id) }, + ) + } + } + } + val recording = dictation as? DictationState.Recording + if (recording != null) { + RecordingChip( + startedAtMs = recording.startedAtMs, + onCancel = onDictationCancel, + modifier = Modifier.padding(bottom = 6.dp), + ) + } + Row( + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + if (onAddAttachment != null) { + ComposerRoundButton( + glyph = PlusGlyph, + contentDescription = stringResource(R.string.chat_composer_add_attachment), + onClick = onAddAttachment, + ) + } + // The input pill: borderless multiline field with the mic + // inline at its trailing edge (chat-bar idiom, not a form + // field — OutlinedTextField's label chrome read as broken). + Surface( + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = RoundedCornerShape(22.dp), + modifier = Modifier.weight(1f), + ) { + Row(verticalAlignment = Alignment.Bottom) { + BasicTextField( + value = state.text, + onValueChange = onTextChange, + textStyle = MaterialTheme.typography.bodyLarge.copy( + color = MaterialTheme.colorScheme.onSurface, + ), + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + maxLines = 6, + modifier = Modifier + .weight(1f) + .padding(start = 14.dp, end = 4.dp, top = 11.dp, bottom = 11.dp), + decorationBox = { inner -> + Box { + if (state.text.isEmpty()) { + Text( + text = stringResource(R.string.chat_composer_placeholder), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.hapi.hint, + ) + } + inner() + } + }, + ) + if (dictation != null) { + MicButton(state = dictation, onToggle = onDictationToggle) + } + } + } + if (state.canSteer) { + ComposerRoundButton( + glyph = StopGlyph, + contentDescription = stringResource(R.string.chat_composer_abort), + onClick = onAbort, + color = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + ) + } + SendButton(state = state, attachments = attachments, onSend = onSend, onSendSteer = onSendSteer) + } + } + } +} + +// ---------------------------------------------------------- attachments -- + +/** + * One tray chip: 36 dp thumb (image preview / MIME glyph, spinner while + * uploading), filename + status line, ✕ to remove. A failed chip tints error + * and taps to retry. + */ +@Composable +private fun ComposerAttachmentChip( + attachment: ComposerAttachmentUi, + onRetry: () -> Unit, + onRemove: () -> Unit, +) { + val failed = attachment.status == ComposerAttachmentStatus.Failed + Surface( + color = if (failed) { + MaterialTheme.colorScheme.errorContainer + } else { + MaterialTheme.colorScheme.surfaceContainerHigh + }, + contentColor = if (failed) { + MaterialTheme.colorScheme.onErrorContainer + } else { + MaterialTheme.colorScheme.onSurface + }, + shape = RoundedCornerShape(10.dp), + onClick = { if (failed) onRetry() }, + enabled = failed, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 6.dp, top = 6.dp, bottom = 6.dp), + ) { + ChipThumb(attachment) + Column(modifier = Modifier.padding(start = 8.dp).widthIn(max = 132.dp)) { + Text( + text = attachment.filename, + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = when (attachment.status) { + ComposerAttachmentStatus.Uploading -> stringResource(R.string.chat_attachment_uploading) + ComposerAttachmentStatus.Failed -> stringResource(R.string.chat_attachment_failed_retry) + ComposerAttachmentStatus.Ready -> formatChipSize(attachment.sizeBytes) + }, + style = MaterialTheme.typography.labelSmall, + color = if (failed) MaterialTheme.colorScheme.error else MaterialTheme.hapi.hint, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Text( + text = "✕", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.hapi.hint, + modifier = Modifier + .clip(CircleShape) + .clickable(onClick = onRemove) + .padding(horizontal = 10.dp, vertical = 8.dp), + ) + } + } +} + +@Composable +private fun ChipThumb(attachment: ComposerAttachmentUi) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(36.dp) + .clip(RoundedCornerShape(8.dp)), + ) { + val thumbnail = rememberChipThumbnail(attachment.previewBytes) + if (thumbnail != null) { + Image( + bitmap = thumbnail, + contentDescription = attachment.filename, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(36.dp) + .graphicsLayer { + alpha = if (attachment.status == ComposerAttachmentStatus.Uploading) 0.4f else 1f + }, + ) + } else { + Surface(color = MaterialTheme.colorScheme.surfaceContainerHighest, modifier = Modifier.size(36.dp)) {} + Text(text = if (attachment.mimeType.startsWith("image/")) "🖼" else "📎", fontSize = 15.sp) + } + if (attachment.status == ComposerAttachmentStatus.Uploading) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) + } + } +} + +/** `12.3 MB` / `456 KB` / `789 B` chip size label. */ +internal fun formatChipSize(bytes: Long): String = when { + bytes >= 1024 * 1024 -> "%.1f MB".format(bytes / (1024.0 * 1024.0)) + bytes >= 1024 -> "%.0f KB".format(bytes / 1024.0) + else -> "$bytes B" +} + +/** Shared 42 dp round action button (glyph icon, tinted via contentColor). */ +@Composable +private fun ComposerRoundButton( + glyph: androidx.compose.ui.graphics.vector.ImageVector, + contentDescription: String?, + onClick: () -> Unit, + modifier: Modifier = Modifier, + color: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.surfaceContainerHigh, + contentColor: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.onSurfaceVariant, + enabled: Boolean = true, +) { + Surface( + color = color, + contentColor = contentColor, + shape = CircleShape, + enabled = enabled, + onClick = onClick, + modifier = modifier.size(42.dp), + ) { + Box(contentAlignment = Alignment.Center) { + Icon(glyph, contentDescription = contentDescription, modifier = Modifier.size(20.dp)) + } + } +} + +// -------------------------------------------------------- slash dropdown -- + +/** + * Filtered command list above the input (web `Autocomplete.tsx` twin): + * name + description rows, tap inserts `/name `. + */ +@Composable +private fun SlashCommandDropdown( + suggestions: List, + onSelect: (SlashCommand) -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = RoundedCornerShape(12.dp), + tonalElevation = 2.dp, + modifier = modifier.fillMaxWidth(), + ) { + LazyColumn(modifier = Modifier.heightIn(max = 240.dp)) { + items(suggestions, key = { "${it.source}:${it.name}" }) { command -> + Column( + modifier = Modifier + .fillMaxWidth() + .combinedClickable(onClick = { onSelect(command) }) + .padding(horizontal = 12.dp, vertical = 8.dp), + ) { + Text( + text = "/${command.name}", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + command.description?.takeIf { it.isNotBlank() }?.let { description -> + Text( + text = description, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.hapi.hint, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + HorizontalDivider(color = MaterialTheme.colorScheme.surfaceContainerHighest) + } + } + } +} + +// ------------------------------------------------------------- dictation -- + +/** Elapsed-time recording chip with a cancel affordance (discards the take). */ +@Composable +private fun RecordingChip( + startedAtMs: Long, + onCancel: () -> Unit, + modifier: Modifier = Modifier, + /** Injectable clock for previews. */ + now: () -> Long = System::currentTimeMillis, +) { + var elapsedSec by remember(startedAtMs) { + mutableLongStateOf(((now() - startedAtMs) / 1000).coerceAtLeast(0)) + } + LaunchedEffect(startedAtMs) { + while (true) { + elapsedSec = ((now() - startedAtMs) / 1000).coerceAtLeast(0) + delay(250) + } + } + Surface( + color = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + shape = RoundedCornerShape(12.dp), + modifier = modifier.fillMaxWidth(), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringResource(R.string.chat_recording, formatElapsed(elapsedSec)), + style = MaterialTheme.typography.labelMedium, + modifier = Modifier + .weight(1f) + .padding(start = 12.dp, top = 4.dp, bottom = 4.dp), + ) + TextButton(onClick = onCancel) { Text(stringResource(R.string.chat_cancel)) } + } + } +} + +/** `m:ss` elapsed-time label for the recording chip. */ +internal fun formatElapsed(totalSeconds: Long): String { + val minutes = totalSeconds / 60 + val seconds = totalSeconds % 60 + return "%d:%02d".format(minutes, seconds) +} + +/** + * Press-to-toggle mic: idle glyph → recording stop-square (error colors) → + * spinner while starting/transcribing. + */ +@Composable +private fun MicButton(state: DictationState, onToggle: () -> Unit) { + val recording = state is DictationState.Recording + val busy = state is DictationState.Starting || state is DictationState.Transcribing + Surface( + color = if (recording) { + MaterialTheme.colorScheme.errorContainer + } else { + androidx.compose.ui.graphics.Color.Transparent + }, + contentColor = if (recording) { + MaterialTheme.colorScheme.onErrorContainer + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + shape = CircleShape, + enabled = !busy, + onClick = onToggle, + modifier = Modifier + .padding(end = 4.dp, bottom = 4.dp) + .size(38.dp), + ) { + Box(contentAlignment = Alignment.Center) { + when { + busy -> CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.hapi.hint, + ) + recording -> Icon( + StopGlyph, + contentDescription = stringResource(R.string.chat_composer_stop_recording), + modifier = Modifier.size(18.dp), + ) + else -> Icon( + MicGlyph, + contentDescription = stringResource(R.string.chat_composer_mic), + modifier = Modifier.size(19.dp), + ) + } + } + } +} + +// ---------------------------------------------------------------- actions -- + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun SendButton( + state: ComposerUiState, + attachments: List, + onSend: () -> Unit, + onSendSteer: () -> Unit, +) { + var steerMenuOpen by remember { mutableStateOf(false) } + val hasText = state.text.isNotBlank() + // Attachments gate the send like the web: every chip must settle Ready + // (uploading waits, failed must be retried or removed); a ready tray + // allows an attachments-only send (wire: text or attachments required). + val attachmentsBusy = attachments.any { it.status != ComposerAttachmentStatus.Ready } + val attachmentsReady = attachments.isNotEmpty() && !attachmentsBusy + val enabled = (hasText || attachmentsReady) && !attachmentsBusy && !state.isSending + + Box( + modifier = Modifier + .size(42.dp) + .clip(CircleShape), + ) { + Surface( + color = if (enabled) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.surfaceContainerHigh + }, + contentColor = if (enabled) { + MaterialTheme.colorScheme.onPrimary + } else { + MaterialTheme.hapi.hint + }, + shape = CircleShape, + modifier = Modifier + .size(42.dp) + .combinedClickable( + enabled = enabled, + onClick = onSend, + // Steer intent is deliberate: only offered while a turn is + // active (`messageDelivery.ts` — queue is always the default). + onLongClick = { if (state.canSteer) steerMenuOpen = true }, + ), + ) { + Box(contentAlignment = Alignment.Center) { + if (state.isSending) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = MaterialTheme.hapi.hint, + ) + } else { + Icon( + ArrowUpGlyph, + contentDescription = stringResource(R.string.chat_composer_send), + modifier = Modifier.size(20.dp), + ) + } + } + } + DropdownMenu(expanded = steerMenuOpen, onDismissRequest = { steerMenuOpen = false }) { + DropdownMenuItem( + text = { Text(stringResource(R.string.chat_send_steer)) }, + onClick = { + steerMenuOpen = false + onSendSteer() + }, + ) + } + } +} + +// -------------------------------------------------------------- previews -- + +@Preview(showBackground = true) +@Composable +private fun ChatComposerPreview() { + HapiTheme { + Surface { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + ChatComposer( + state = ComposerUiState(text = "", isSending = false, canSteer = false), + onTextChange = {}, onSend = {}, onSendSteer = {}, onAbort = {}, + dictation = DictationState.Idle, + ) + ChatComposer( + state = ComposerUiState( + text = "Run the tests and summarize failures", + isSending = false, + canSteer = true, + ), + onTextChange = {}, onSend = {}, onSendSteer = {}, onAbort = {}, + dictation = DictationState.Idle, + ) + ChatComposer( + state = ComposerUiState(text = "Sending…", isSending = true, canSteer = false), + onTextChange = {}, onSend = {}, onSendSteer = {}, onAbort = {}, + onAddAttachment = {}, + attachments = listOf( + ComposerAttachmentUi( + id = "a1", + filename = "screenshot.png", + mimeType = "image/png", + sizeBytes = 1_843_200, + previewBytes = null, + status = ComposerAttachmentStatus.Ready, + ), + ), + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun AttachmentChipsComposerPreview() { + HapiTheme { + Surface { + ChatComposer( + state = ComposerUiState(text = "", isSending = false, canSteer = false), + onTextChange = {}, onSend = {}, onSendSteer = {}, onAbort = {}, + onAddAttachment = {}, + attachments = listOf( + ComposerAttachmentUi( + id = "up", + filename = "IMG_20260818_133702.jpg", + mimeType = "image/jpeg", + sizeBytes = 2_411_000, + previewBytes = null, + status = ComposerAttachmentStatus.Uploading, + ), + ComposerAttachmentUi( + id = "ok", + filename = "build-log.txt", + mimeType = "text/plain", + sizeBytes = 48_500, + previewBytes = null, + status = ComposerAttachmentStatus.Ready, + ), + ComposerAttachmentUi( + id = "bad", + filename = "trace.bin", + mimeType = "application/octet-stream", + sizeBytes = 9_000_000, + previewBytes = null, + status = ComposerAttachmentStatus.Failed, + ), + ), + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun RecordingComposerPreview() { + HapiTheme { + Surface { + ChatComposer( + state = ComposerUiState(text = "", isSending = false, canSteer = false), + onTextChange = {}, onSend = {}, onSendSteer = {}, onAbort = {}, + dictation = DictationState.Recording(startedAtMs = System.currentTimeMillis() - 42_000), + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun SlashDropdownComposerPreview() { + HapiTheme { + Surface { + ChatComposer( + state = ComposerUiState(text = "/co", isSending = false, canSteer = false), + onTextChange = {}, onSend = {}, onSendSteer = {}, onAbort = {}, + dictation = DictationState.Idle, + slashSuggestions = listOf( + SlashCommand( + name = "compact", + description = "Clear conversation history but keep a summary in context", + source = "builtin", + ), + SlashCommand( + name = "context", + description = "Visualize current context usage as a colored grid", + source = "builtin", + ), + SlashCommand(name = "code-review", description = null, source = "project"), + ), + ) + } + } +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/chat/composer/ChatDrafts.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/composer/ChatDrafts.kt new file mode 100644 index 0000000000..9325427e98 --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/composer/ChatDrafts.kt @@ -0,0 +1,19 @@ +package app.hapi.companion.feature.chat.composer + +/** + * Per-session composer draft persistence (B-M3a). Production backing is a + * Preferences DataStore ([app.hapi.companion.di.DataStoreChatDrafts], keys + * scoped by hub); tests use an in-memory map. + */ +interface ChatDrafts { + /** The saved draft, or null when none. */ + suspend fun load(sessionId: String): String? + + /** Persist [text]; blank clears the key. */ + suspend fun save(sessionId: String, text: String) + + suspend fun clear(sessionId: String) + + /** Resume/reopen returned a different id: carry the draft across. */ + suspend fun move(fromSessionId: String, toSessionId: String) +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/chat/composer/ComposerGlyphs.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/composer/ComposerGlyphs.kt new file mode 100644 index 0000000000..fe92ff2d6f --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/composer/ComposerGlyphs.kt @@ -0,0 +1,57 @@ +package app.hapi.companion.feature.chat.composer + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.addPathNodes +import androidx.compose.ui.unit.dp + +// Hand-drawn 24 dp stroke glyphs (the FolderGlyph precedent) — no +// material-icons-extended dependency, and no emoji-as-icon (device fonts +// render those inconsistently, which is exactly what made the first composer +// build look broken). + +private fun strokeIcon(name: String, pathData: String, strokeWidth: Float = 1.8f): ImageVector = + ImageVector.Builder( + name = name, + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, + ).apply { + addPath( + pathData = addPathNodes(pathData), + fill = null, + // Any opaque stroke works: Icon() recolors via ColorFilter tint. + stroke = SolidColor(Color.Black), + strokeLineWidth = strokeWidth, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round, + ) + }.build() + +/** "+" — opens the attachment picker sheet. */ +internal val PlusGlyph: ImageVector by lazy { + strokeIcon("HapiPlus", "M12 5 L12 19 M5 12 L19 12", 2f) +} + +/** Microphone — dictation toggle. */ +internal val MicGlyph: ImageVector by lazy { + strokeIcon( + "HapiMic", + "M12 3 a3 3 0 0 1 3 3 v5 a3 3 0 0 1 -6 0 v-5 a3 3 0 0 1 3 -3 " + + "M6.5 11 a5.5 5.5 0 0 0 11 0 M12 16.5 L12 20.5 M9 20.5 L15 20.5", + ) +} + +/** Stop square — abort while a turn runs, and the recording-stop state. */ +internal val StopGlyph: ImageVector by lazy { + strokeIcon("HapiStop", "M8 8 h8 v8 h-8 z", 2f) +} + +/** Upward arrow — send. */ +internal val ArrowUpGlyph: ImageVector by lazy { + strokeIcon("HapiArrowUp", "M12 19 L12 5.5 M6.5 11 L12 5.5 L17.5 11", 2.2f) +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/chat/composer/Dictation.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/composer/Dictation.kt new file mode 100644 index 0000000000..735253869a --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/composer/Dictation.kt @@ -0,0 +1,243 @@ +package app.hapi.companion.feature.chat.composer + +import app.hapi.data.api.HapiApi +import app.hapi.protocol.wire.TranscriptionProvidersResponse +import app.hapi.protocol.wire.TranscriptionResponse +import kotlin.coroutines.cancellation.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +/** + * Append a finished transcript to the composer text with a single space + * separator (web `appendTranscript`, `useDictation.ts`). + */ +fun appendTranscript(text: String, transcript: String): String { + val addition = transcript.trim() + if (addition.isEmpty()) return text + if (text.isEmpty()) return addition + val separator = if (text.last().isWhitespace()) "" else " " + return "$text$separator$addition" +} + +/** Transport seam over the hub's two dictation endpoints (fake in tests). */ +interface DictationApi { + /** `GET /api/voice/transcription/providers`. */ + suspend fun transcriptionProviders(): TranscriptionProvidersResponse + + /** `POST /api/voice/transcription` (multipart, `mode=standard`). */ + suspend fun transcribe( + audio: ByteArray, + filename: String, + mimeType: String, + provider: String, + language: String?, + ): TranscriptionResponse +} + +/** Production [DictationApi] over [HapiApi]. */ +class HapiDictationApi(private val api: HapiApi) : DictationApi { + override suspend fun transcriptionProviders(): TranscriptionProvidersResponse = + api.getTranscriptionProviders() + + override suspend fun transcribe( + audio: ByteArray, + filename: String, + mimeType: String, + provider: String, + language: String?, + ): TranscriptionResponse = + api.transcribeVoice(audio, filename, mimeType, provider, mode = "standard", language = language) +} + +/** + * Recorder seam (production: [MediaRecorderDictation] — AAC/m4a via the + * framework `MediaRecorder`; tests: scripted bytes). + */ +interface DictationRecorder { + /** Container filename the produced audio should upload under (`speech.m4a`). */ + val filename: String + + /** MIME type of the produced audio (`audio/mp4`). */ + val mimeType: String + + /** Begin capturing; throws when the microphone cannot be opened. */ + fun start() + + /** Stop and finalize the take; the recorded bytes, or null when nothing usable was captured. */ + fun stop(): ByteArray? + + /** Abandon the take, discarding any captured data. */ + fun cancel() +} + +/** The mic button / recording chip state machine. */ +sealed interface DictationState { + /** No take in progress — mic button shows the idle glyph. */ + data object Idle : DictationState + + /** Provider discovery / recorder spin-up after the first press. */ + data object Starting : DictationState + + /** Capturing; the chip derives elapsed time from [startedAtMs]. */ + data class Recording(val startedAtMs: Long) : DictationState + + /** Upload + transcription in flight — mic button shows a spinner. */ + data object Transcribing : DictationState +} + +/** One-shot dictation outcomes for the screen. */ +sealed interface DictationEvent { + /** Final transcript — append to the composer via [appendTranscript]. */ + data class Transcribed(val text: String) : DictationEvent + + /** The hub has no transcription provider configured. */ + data object NoProvider : DictationEvent + + /** + * Recording or transcription failed (snackbar). [kind] localizes at the + * UI layer; [detail] is server/exception text shown verbatim when present. + */ + data class Error(val kind: DictationErrorKind, val detail: String? = null) : DictationEvent +} + +/** Semantic dictation failure kinds (B-M5a) — resolved to strings by the UI. */ +enum class DictationErrorKind { + StartFailed, + HubUnreachable, + RecordingFailed, + NoAudio, + TranscriptionFailed, +} + +/** + * Press-to-toggle dictation (B-M3ce): first [toggle] discovers a provider + * (`GET /providers`, first entry supporting `standard`; memoized) and starts + * the recorder; the second stops it and posts the audio to + * `POST /api/voice/transcription`, emitting [DictationEvent.Transcribed] + * with the hub's text. [cancel] abandons the take without uploading. + * + * Web reference: `useDictation.ts` (standard mode; the realtime path is out + * of scope for v1). No language override yet — the hub auto-detects; a + * settings-backed `language` field is the M5 hook. + * + * Plain constructor over two seams — JVM tests drive it with fakes. + */ +class DictationController( + private val api: DictationApi, + private val recorder: DictationRecorder, + private val scope: CoroutineScope, + private val now: () -> Long = System::currentTimeMillis, +) { + private val _state = MutableStateFlow(DictationState.Idle) + val state: StateFlow = _state.asStateFlow() + + private val _events = MutableSharedFlow(extraBufferCapacity = 8) + val events: SharedFlow = _events.asSharedFlow() + + /** First successful discovery wins for the session (web keeps a provider setting; v1 has none). */ + private var cachedProviderId: String? = null + + private var job: Job? = null + + /** Mic button press: Idle → record, Recording → stop + transcribe. */ + fun toggle() { + when (_state.value) { + DictationState.Idle -> startRecording() + is DictationState.Recording -> stopAndTranscribe() + // Starting/Transcribing: an operation is already in flight. + DictationState.Starting, DictationState.Transcribing -> Unit + } + } + + /** Recording chip ✕: discard the take (no upload). */ + fun cancel() { + if (_state.value !is DictationState.Recording) return + runCatching { recorder.cancel() } + _state.value = DictationState.Idle + } + + private fun startRecording() { + if (job?.isActive == true) return + _state.value = DictationState.Starting + job = scope.launch { + val provider = cachedProviderId ?: discoverProvider() ?: run { + _state.value = DictationState.Idle + return@launch + } + cachedProviderId = provider + try { + recorder.start() + _state.value = DictationState.Recording(now()) + } catch (cancellation: CancellationException) { + throw cancellation + } catch (error: Exception) { + _events.tryEmit(DictationEvent.Error(DictationErrorKind.StartFailed, error.message)) + _state.value = DictationState.Idle + } + } + } + + /** @return the chosen provider id, or null after emitting the failure event. */ + private suspend fun discoverProvider(): String? { + val providers = try { + api.transcriptionProviders().providers + } catch (cancellation: CancellationException) { + throw cancellation + } catch (error: Exception) { + _events.tryEmit(DictationEvent.Error(DictationErrorKind.HubUnreachable, error.message)) + return null + } + // First provider supporting standard (uploaded-file) transcription — + // the hub lists them in its own preference order. `browser-local` + // (realtime-only) never qualifies. + val chosen = providers.firstOrNull { it.modes.contains("standard") } + if (chosen == null) { + _events.tryEmit(DictationEvent.NoProvider) + } + return chosen?.id + } + + private fun stopAndTranscribe() { + val provider = cachedProviderId ?: return // unreachable: set before Recording + _state.value = DictationState.Transcribing + job = scope.launch { + val audio = try { + recorder.stop() + } catch (cancellation: CancellationException) { + throw cancellation + } catch (error: Exception) { + _events.tryEmit(DictationEvent.Error(DictationErrorKind.RecordingFailed, error.message)) + _state.value = DictationState.Idle + return@launch + } + if (audio == null || audio.isEmpty()) { + _events.tryEmit(DictationEvent.Error(DictationErrorKind.NoAudio)) + _state.value = DictationState.Idle + return@launch + } + try { + val result = api.transcribe( + audio = audio, + filename = recorder.filename, + mimeType = recorder.mimeType, + provider = provider, + language = null, + ) + _events.tryEmit(DictationEvent.Transcribed(result.text)) + } catch (cancellation: CancellationException) { + throw cancellation + } catch (error: Exception) { + _events.tryEmit(DictationEvent.Error(DictationErrorKind.TranscriptionFailed, error.message)) + } finally { + _state.value = DictationState.Idle + } + } + } +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/chat/composer/MediaRecorderDictation.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/composer/MediaRecorderDictation.kt new file mode 100644 index 0000000000..47da5de8d9 --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/composer/MediaRecorderDictation.kt @@ -0,0 +1,82 @@ +package app.hapi.companion.feature.chat.composer + +import android.content.Context +import android.media.MediaRecorder +import android.os.Build +import java.io.File + +/** + * [DictationRecorder] over the framework [MediaRecorder]: AAC in an MPEG-4 + * container (`.m4a`), a format every hub transcription provider accepts (the + * contract allows any `audio/` type or mp4; the web itself falls back to mp4 + * on Safari). Mono 44.1 kHz @ 96 kbps keeps minutes of speech far below the + * endpoint's 25 MB cap. Requires `RECORD_AUDIO` — the composer requests it + * before [start]. + */ +class MediaRecorderDictation(private val context: Context) : DictationRecorder { + override val filename: String = "speech.m4a" + override val mimeType: String = "audio/mp4" + + private var recorder: MediaRecorder? = null + private var output: File? = null + + override fun start() { + check(recorder == null) { "recording already in progress" } + val file = File.createTempFile("dictation-", ".m4a", context.cacheDir) + val mediaRecorder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + MediaRecorder(context) + } else { + @Suppress("DEPRECATION") + MediaRecorder() + } + try { + mediaRecorder.setAudioSource(MediaRecorder.AudioSource.MIC) + mediaRecorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) + mediaRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC) + mediaRecorder.setAudioChannels(1) + mediaRecorder.setAudioSamplingRate(44_100) + mediaRecorder.setAudioEncodingBitRate(96_000) + mediaRecorder.setOutputFile(file.absolutePath) + mediaRecorder.prepare() + mediaRecorder.start() + } catch (error: Exception) { + mediaRecorder.release() + file.delete() + throw error + } + recorder = mediaRecorder + output = file + } + + override fun stop(): ByteArray? { + val mediaRecorder = recorder ?: return null + val file = output + recorder = null + output = null + try { + mediaRecorder.stop() + } catch (_: Exception) { + // stop() throws when no valid audio landed (stopped immediately + // after start) — treat as an empty take. + file?.delete() + return null + } finally { + mediaRecorder.release() + } + val bytes = try { + file?.takeIf { it.exists() }?.readBytes() + } finally { + file?.delete() + } + return bytes?.takeIf { it.isNotEmpty() } + } + + override fun cancel() { + val mediaRecorder = recorder ?: return + recorder = null + runCatching { mediaRecorder.stop() } + mediaRecorder.release() + output?.delete() + output = null + } +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/chat/composer/QueuedMessagesBar.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/composer/QueuedMessagesBar.kt new file mode 100644 index 0000000000..db3ed6f54d --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/composer/QueuedMessagesBar.kt @@ -0,0 +1,163 @@ +package app.hapi.companion.feature.chat.composer + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import app.hapi.companion.R +import app.hapi.companion.feature.chat.QueuedRowUi +import app.hapi.companion.ui.theme.HapiTheme +import app.hapi.companion.ui.theme.hapi +import java.text.DateFormat +import java.util.Date + +/** + * Floating bar above the composer for queued (uninvoked) sends — the Compose + * twin of `QueuedMessagesBar.tsx`. Per row: Steer (while a turn is active), + * Edit (cancel + prefill composer) and Cancel. Rows without a server echo yet + * (`id == localId`) keep their actions disabled until the SSE echo lands. + */ +@Composable +fun QueuedMessagesBar( + rows: List, + onSteer: (messageId: String) -> Unit, + onRetry: (messageId: String) -> Unit, + onEdit: (messageId: String) -> Unit, + onCancel: (messageId: String) -> Unit, + modifier: Modifier = Modifier, +) { + if (rows.isEmpty()) return + + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 10.dp, vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = if (rows.size == 1) { + stringResource(R.string.chat_queued_one) + } else { + stringResource(R.string.chat_queued_many, rows.size) + }, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.hapi.hint, + ) + Column( + modifier = Modifier + .heightIn(max = 160.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + rows.forEach { row -> + QueuedRow(row, onSteer, onRetry, onEdit, onCancel) + } + } + } +} + +@Composable +private fun QueuedRow( + row: QueuedRowUi, + onSteer: (String) -> Unit, + onRetry: (String) -> Unit, + onEdit: (String) -> Unit, + onCancel: (String) -> Unit, +) { + Surface( + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = RoundedCornerShape(10.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.padding(start = 10.dp, end = 2.dp, top = 4.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = row.text.ifEmpty { row.attachmentNames.joinToString(", ") }, + style = MaterialTheme.typography.bodySmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + if (row.indeterminate) { + Text( + text = "Delivery outcome unknown", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.error, + ) + } + row.scheduledAt?.let { scheduledAt -> + Text( + text = stringResource( + R.string.chat_queued_scheduled, + DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(scheduledAt)), + ), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.hapi.hint, + ) + } + } + if (row.indeterminate) { + TextButton(onClick = { onRetry(row.id) }, enabled = row.canAct) { Text("Retry") } + } else if (row.canSteer) { + TextButton(onClick = { onSteer(row.id) }) { Text(stringResource(R.string.chat_queued_steer)) } + } + TextButton(onClick = { onEdit(row.id) }, enabled = row.canAct) { Text(stringResource(R.string.chat_queued_edit)) } + TextButton(onClick = { onCancel(row.id) }, enabled = row.canAct) { + Text(stringResource(R.string.chat_cancel), color = MaterialTheme.colorScheme.error) + } + } + } +} + +// -------------------------------------------------------------- previews -- + +@Preview(showBackground = true) +@Composable +private fun QueuedMessagesBarPreview() { + HapiTheme { + Surface { + QueuedMessagesBar( + rows = listOf( + QueuedRowUi( + id = "m1", localId = "l1", + text = "Also add tests for the pagination edge cases", + attachmentNames = emptyList(), + scheduledAt = null, canAct = true, canSteer = true, + ), + QueuedRowUi( + id = "l2", localId = "l2", + text = "Waiting for server echo…", + attachmentNames = emptyList(), + scheduledAt = null, canAct = false, canSteer = false, + ), + QueuedRowUi( + id = "m3", localId = "l3", + text = "Ship it", + attachmentNames = listOf("notes.txt"), + scheduledAt = System.currentTimeMillis() + 3_600_000, + canAct = true, canSteer = false, + ), + ), + onSteer = {}, onRetry = {}, onEdit = {}, onCancel = {}, + ) + } + } +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/chat/composer/SlashCommands.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/composer/SlashCommands.kt new file mode 100644 index 0000000000..59bd909eb7 --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/composer/SlashCommands.kt @@ -0,0 +1,71 @@ +package app.hapi.companion.feature.chat.composer + +import app.hapi.protocol.wire.SlashCommand + +/** + * Pure logic behind the composer's `/` autocomplete (B-M3ce): trigger + * detection, source merging, and filtering. The UI dropdown lives in + * `ChatComposer`; `ChatViewModel` drives these over the session's + * `metadata.slashCommands` + the `GET /slash-commands` RPC result. + * + * The skills `$` trigger is deferred (needs the `GET /skills` source and the + * web's `$`-mention insert semantics) — `/` only for now. + */ +object SlashCommands { + /** + * The active slash query: non-null while the composer text is a single + * `/`-led token at the start of input (`/`, `/co`, `/foo:bar`) — any + * whitespace closes the menu. Returns the text after `/` (may be empty). + */ + fun queryOf(text: String): String? { + if (!text.startsWith("/")) return null + val token = text.substring(1) + if (token.any { it.isWhitespace() }) return null + return token + } + + /** + * Merge the session's bare `metadata.slashCommands` names with the + * RPC-discovered list (which carries descriptions/sources), deduped + * case-insensitively. Later entries win and take the later position (web + * `mergeSlashCommands` semantics), so RPC data overrides bare names. + */ + fun merge(metadataNames: List?, fetched: List?): List { + val combined = ArrayList() + metadataNames?.forEach { raw -> + val name = raw.trim().removePrefix("/") + if (name.isNotEmpty()) combined += SlashCommand(name = name, source = "session") + } + fetched?.let(combined::addAll) + val byName = LinkedHashMap() + for (command in combined) { + val key = command.name.lowercase() + byName.remove(key) + byName[key] = command + } + return byName.values.toList() + } + + /** + * Case-insensitive filter, ranked exact → prefix → contains, stable + * within a rank. (The web adds a levenshtein tail; B-M3ce keeps the + * simple list.) An empty [query] returns everything. + */ + fun filter(commands: List, query: String): List { + if (query.isEmpty()) return commands + val term = query.lowercase() + return commands + .mapNotNull { command -> + val name = command.name.lowercase() + val rank = when { + name == term -> 0 + name.startsWith(term) -> 1 + name.contains(term) -> 2 + else -> return@mapNotNull null + } + command to rank + } + .sortedBy { it.second } + .map { it.first } + } +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/chat/permissions/PermissionInputs.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/permissions/PermissionInputs.kt new file mode 100644 index 0000000000..deab1b7219 --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/permissions/PermissionInputs.kt @@ -0,0 +1,171 @@ +package app.hapi.companion.feature.chat.permissions + +import app.hapi.protocol.wire.arrayOrNull +import app.hapi.protocol.wire.boolOrNull +import app.hapi.protocol.wire.objOrNull +import app.hapi.protocol.wire.stringOrNull +import kotlinx.serialization.json.JsonElement + +/** + * Question/option models + input parsers for the two interactive request + * tools, ported from `web/src/components/ToolCard/askUserQuestion.ts`, + * `cursorAskQuestion.ts` and `requestUserInput.ts`. Pure JVM — unit-tested + * alongside the ViewModel. + */ + +data class AskOption( + /** Stable option id (Cursor ACP); falls back to the label on submit. */ + val id: String?, + val label: String, + val description: String?, +) + +data class AskQuestion( + /** Stable question id (Cursor ACP); falls back to the index on submit. */ + val id: String?, + val header: String?, + val question: String, + val options: List, + val multiSelect: Boolean, +) { + /** The flat-answers key: stable id when present, else the index (web parity). */ + fun answerKey(index: Int, useStableIds: Boolean): String = + if (useStableIds && !id.isNullOrBlank()) id else index.toString() +} + +fun isCursorAskQuestionToolName(toolName: String): Boolean = toolName == "CursorAskQuestion" + +/** + * `parseAskUserQuestionInput` / `parseCursorAskQuestionInput` merged: the + * Cursor dialect adds `prompt`/`title`/`allowMultiple` synonyms and stable + * ids; both collapse to the same [AskQuestion] list. + */ +fun parseAskUserQuestions(input: JsonElement?, cursorDialect: Boolean): List { + val root = input.objOrNull ?: return emptyList() + val rawQuestions = root["questions"].arrayOrNull ?: return emptyList() + val requestTitle = if (cursorDialect) root["title"].stringOrNull?.trim().orEmpty() else "" + + val questions = mutableListOf() + for (raw in rawQuestions) { + val obj = raw.objOrNull ?: continue + + val question = if (cursorDialect) { + obj["prompt"].stringOrNull?.trim() ?: obj["question"].stringOrNull?.trim().orEmpty() + } else { + obj["question"].stringOrNull?.trim().orEmpty() + } + val header = if (cursorDialect) { + obj["title"].stringOrNull?.trim() ?: obj["header"].stringOrNull?.trim().orEmpty() + } else { + obj["header"].stringOrNull?.trim().orEmpty() + } + val multiSelect = if (cursorDialect) { + obj["allowMultiple"].boolOrNull == true || obj["multiSelect"].boolOrNull == true + } else { + obj["multiSelect"].boolOrNull == true + } + val questionId = if (cursorDialect) { + obj["id"].stringOrNull?.trim()?.takeIf { it.isNotEmpty() } ?: questions.size.toString() + } else { + null + } + + val options = mutableListOf() + for (rawOption in obj["options"].arrayOrNull ?: emptyList()) { + val optionObj = rawOption.objOrNull ?: continue + val label = if (cursorDialect) { + optionObj["label"].stringOrNull?.trim() ?: optionObj["id"].stringOrNull?.trim().orEmpty() + } else { + optionObj["label"].stringOrNull?.trim().orEmpty() + } + if (label.isEmpty()) continue + val optionId = if (cursorDialect) { + optionObj["id"].stringOrNull?.trim()?.takeIf { it.isNotEmpty() } ?: label + } else { + null + } + val description = if (cursorDialect) null else optionObj["description"].stringOrNull?.trim() + options += AskOption(id = optionId, label = label, description = description?.takeIf { it.isNotEmpty() }) + } + + if (question.isEmpty() && options.isEmpty()) continue + + questions += AskQuestion( + id = questionId, + header = header.ifEmpty { requestTitle.ifEmpty { null } }, + question = question, + options = options, + multiSelect = multiSelect, + ) + } + return questions +} + +data class RequestUserInputQuestion( + val id: String, + val question: String, + val required: Boolean, + val multiple: Boolean, + val options: List, + val placeholder: String?, + val prefill: String?, +) + +/** `parseRequestUserInputInput` (the URL-confirmation flow is web-only). */ +fun parseRequestUserInputQuestions(input: JsonElement?): List { + val root = input.objOrNull ?: return emptyList() + val rawQuestions = root["questions"].arrayOrNull ?: return emptyList() + + val questions = mutableListOf() + for (raw in rawQuestions) { + val obj = raw.objOrNull ?: continue + val id = obj["id"].stringOrNull?.trim().orEmpty() + if (id.isEmpty()) continue + + val options = mutableListOf() + for (rawOption in obj["options"].arrayOrNull ?: emptyList()) { + val optionObj = rawOption.objOrNull ?: continue + val label = optionObj["label"].stringOrNull?.trim().orEmpty() + if (label.isEmpty()) continue + options += AskOption( + id = null, + label = label, + description = optionObj["description"].stringOrNull?.trim()?.takeIf { it.isNotEmpty() }, + ) + } + + questions += RequestUserInputQuestion( + id = id, + question = obj["question"].stringOrNull?.trim().orEmpty(), + required = obj["required"].boolOrNull != false, + multiple = obj["multiple"].boolOrNull == true, + options = options, + placeholder = obj["placeholder"].stringOrNull, + prefill = obj["prefill"].stringOrNull, + ) + } + return questions +} + +/** + * `formatRequestUserInputAnswers` value building for ONE field: selected + * option labels plus a trailing `user_note: ` entry when a note was + * typed. The nested `{answers: [...]}` wrapper is applied by the ViewModel. + */ +fun requestUserInputAnswerValues(selected: List, note: String): List { + val values = selected.toMutableList() + val trimmed = note.trim() + if (trimmed.isNotEmpty()) values += "user_note: $trimmed" + return values +} + +/** `isRequestUserInputQuestionAnswered`. */ +fun isRequestUserInputAnswered( + question: RequestUserInputQuestion, + selected: List, + note: String, +): Boolean { + if (!question.required) return true + if (question.options.isNotEmpty()) return selected.isNotEmpty() + return note.trim().isNotEmpty() +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/files/FileViewerScreen.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/files/FileViewerScreen.kt new file mode 100644 index 0000000000..e56737a0ed --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/files/FileViewerScreen.kt @@ -0,0 +1,311 @@ +package app.hapi.companion.feature.files + +import android.graphics.BitmapFactory +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import app.hapi.companion.R +import app.hapi.companion.ui.components.DiffView +import app.hapi.companion.ui.markdown.CodeBlock +import app.hapi.companion.ui.markdown.Markdown +import app.hapi.companion.ui.theme.hapi +import kotlinx.coroutines.delay + +/** + * Single-file viewer (`chat/{sessionId}/file`), the Android take on web + * `file.tsx`: diff mode (parsed unified diff in [DiffView], staged/unstaged + * toggle) ⇄ full mode ([CodeBlock] with its 400-line highlight cap and copy + * button; markdown gets a Source/Preview toggle over the shared [Markdown] + * renderer; images decode to a bitmap). Top bar shows the file name with the + * middle-ellipsized path and a copy-path action. + * + * Chat citations may carry a line number; per-line highlighting inside the + * single-`Text` [CodeBlock] isn't cheap, so the viewer shows a "Line N" hint + * chip instead of scrolling/highlighting (noted B-M4c trade-off). + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FileViewerScreen( + viewModel: FileViewerViewModel, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + DisposableEffect(viewModel) { + viewModel.start() + onDispose { } + } + + val state by viewModel.state.collectAsState() + val colors = MaterialTheme.hapi + + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + TopAppBar( + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.files_back)) + } + }, + title = { + Column { + Text( + text = state.fileName, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = formatFileMetadata(state.sizeBytes, state.modifiedAt) ?: state.path, + fontSize = 11.sp, + color = colors.hint, + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis, + ) + } + }, + actions = { + IconButton(onClick = viewModel::refresh) { + Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.files_refresh)) + } + }, + ) + }, + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding), + ) { + PathRow(path = state.path) + ModeToggleRow(state, viewModel) + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + when (state.mode) { + ViewerMode.DIFF -> DiffContent(state) + ViewerMode.FILE -> FileContent(state) + } + } + } + } +} + +@Composable +private fun PathRow(path: String) { + val colors = MaterialTheme.hapi + // Sync clipboard API is the deliberate choice, matching CodeBlock's copy. + @Suppress("DEPRECATION") + val clipboard = LocalClipboardManager.current + var copied by remember { mutableStateOf(false) } + LaunchedEffect(copied) { + if (copied) { + delay(1600) + copied = false + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = path, + fontSize = 12.sp, + color = colors.hint, + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis, + modifier = Modifier.weight(1f), + ) + Text( + text = stringResource(if (copied) R.string.files_viewer_copied else R.string.files_viewer_copy_path), + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + color = if (copied) MaterialTheme.colorScheme.primary else colors.hint, + modifier = Modifier + .clickable { + clipboard.setText(AnnotatedString(path)) + copied = true + } + .padding(horizontal = 8.dp, vertical = 6.dp), + ) + } +} + +@Composable +private fun ModeToggleRow(state: FileViewerUiState, viewModel: FileViewerViewModel) { + val hasDiff = state.diff is DiffUiState.Ready + val isMarkdownFile = (state.content as? FileContentUiState.Text)?.isMarkdown == true + if (!hasDiff && !isMarkdownFile && state.focusLine == null) return + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + if (hasDiff) { + ModeChip(stringResource(R.string.files_viewer_diff), state.mode == ViewerMode.DIFF) { viewModel.setMode(ViewerMode.DIFF) } + ModeChip(stringResource(R.string.files_viewer_file), state.mode == ViewerMode.FILE) { viewModel.setMode(ViewerMode.FILE) } + } + if (hasDiff && state.mode == ViewerMode.DIFF) { + Text("·", color = MaterialTheme.hapi.hint) + ModeChip(stringResource(R.string.files_viewer_unstaged), !state.staged) { viewModel.setStaged(false) } + ModeChip(stringResource(R.string.files_viewer_staged), state.staged) { viewModel.setStaged(true) } + } + if (isMarkdownFile && state.mode == ViewerMode.FILE) { + if (hasDiff) Text("·", color = MaterialTheme.hapi.hint) + ModeChip(stringResource(R.string.files_viewer_source), !state.markdownPreview) { viewModel.setMarkdownPreview(false) } + ModeChip(stringResource(R.string.files_viewer_preview), state.markdownPreview) { viewModel.setMarkdownPreview(true) } + } + state.focusLine?.let { line -> + Text( + text = stringResource(R.string.files_viewer_line, line), + fontSize = 11.sp, + color = MaterialTheme.hapi.hint, + modifier = Modifier.padding(start = 4.dp), + ) + } + } +} + +@Composable +private fun ModeChip(label: String, selected: Boolean, onClick: () -> Unit) { + val colors = MaterialTheme.hapi + Text( + text = label, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + color = if (selected) MaterialTheme.colorScheme.onPrimary else colors.hint, + modifier = Modifier + .background( + color = if (selected) MaterialTheme.colorScheme.primary else colors.codeHeaderBackground, + shape = RoundedCornerShape(50), + ) + .clickable(onClick = onClick) + .padding(horizontal = 10.dp, vertical = 4.dp), + ) +} + +// ---------------------------------------------------------------- content -- + +@Composable +private fun DiffContent(state: FileViewerUiState) { + when (val diff = state.diff) { + DiffUiState.Loading -> LoadingBlock() + DiffUiState.Empty -> HintBlock(stringResource(R.string.files_viewer_no_changes)) + is DiffUiState.Failed -> HintBlock(diff.message) + is DiffUiState.Ready -> diff.files.forEach { file -> + DiffView(file = file, compact = false, modifier = Modifier.fillMaxWidth()) + } + } +} + +@Composable +private fun FileContent(state: FileViewerUiState) { + when (val content = state.content) { + FileContentUiState.Loading -> LoadingBlock() + is FileContentUiState.Failed -> HintBlock(content.message) + FileContentUiState.Empty -> HintBlock(stringResource(R.string.files_viewer_empty)) + FileContentUiState.Binary -> HintBlock(stringResource(R.string.files_viewer_binary)) + is FileContentUiState.Image -> ImageContent(content, state.fileName) + is FileContentUiState.Text -> + if (content.isMarkdown && state.markdownPreview) { + Markdown(text = content.text, modifier = Modifier.fillMaxWidth()) + } else { + CodeBlock( + code = content.text, + language = content.language, + modifier = Modifier.fillMaxWidth(), + ) + } + } +} + +@Composable +private fun ImageContent(content: FileContentUiState.Image, fileName: String) { + val bitmap = remember(content) { + BitmapFactory.decodeByteArray(content.bytes, 0, content.bytes.size)?.asImageBitmap() + } + if (bitmap == null) { + // SVG and other formats BitmapFactory can't decode. + HintBlock(stringResource(R.string.files_viewer_image_unsupported)) + } else { + Image( + bitmap = bitmap, + contentDescription = fileName, + contentScale = ContentScale.Fit, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +@Composable +private fun LoadingBlock() { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(top = 48.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } +} + +@Composable +private fun HintBlock(text: String) { + Text( + text = text, + fontSize = 13.sp, + color = MaterialTheme.hapi.hint, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 16.dp), + ) +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/files/FileViewerViewModel.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/files/FileViewerViewModel.kt new file mode 100644 index 0000000000..9ae900e10d --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/files/FileViewerViewModel.kt @@ -0,0 +1,293 @@ +package app.hapi.companion.feature.files + +import app.hapi.protocol.git.DiffFile +import app.hapi.protocol.git.UnifiedDiffParser +import app.hapi.protocol.wire.FileReadResponse +import java.util.Base64 +import kotlin.coroutines.cancellation.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +// ------------------------------------------------------------- UI models -- + +enum class ViewerMode { DIFF, FILE } + +/** + * Fallback strings the viewer ViewModel needs (B-M5a Strings seam): defaults + * are the pre-i18n English (JVM tests construct without arguments); production + * passes resource-resolved values from the Navigation holder. + */ +class FileViewerStrings( + val loadDiffFailed: String = "Failed to load diff", + val readFileFailed: String = "Failed to read file", +) + +sealed interface DiffUiState { + data object Loading : DiffUiState + + /** Diff succeeded but printed nothing (no changes on this side). */ + data object Empty : DiffUiState + data class Failed(val message: String) : DiffUiState + data class Ready(val files: List) : DiffUiState +} + +sealed interface FileContentUiState { + data object Loading : FileContentUiState + data class Failed(val message: String) : FileContentUiState + data object Empty : FileContentUiState + + /** Undecodable or heuristically binary (web `isBinaryContent`). */ + data object Binary : FileContentUiState + + data class Text( + val text: String, + /** Lowercased extension, fed to the `CodeBlock` highlighter. */ + val language: String?, + val isMarkdown: Boolean, + ) : FileContentUiState + + /** Raw image bytes for `BitmapFactory` (identity equality is fine here). */ + class Image(val bytes: ByteArray, val mimeType: String) : FileContentUiState +} + +data class FileViewerUiState( + val path: String, + val fileName: String, + val mode: ViewerMode, + /** Which diff side is showing (the staged/unstaged toggle). */ + val staged: Boolean, + val diff: DiffUiState = DiffUiState.Loading, + val content: FileContentUiState = FileContentUiState.Loading, + /** Markdown files: render preview instead of source (web default: preview). */ + val markdownPreview: Boolean = true, + val sizeBytes: Long? = null, + val modifiedAt: Long? = null, + /** Requested line from a chat citation; shown as a hint chip (no per-line highlight — see screen note). */ + val focusLine: Int? = null, +) + +/** + * One file, two modes (web `file.tsx`): **diff** — `git-diff-file` stdout + * through `UnifiedDiffParser` into `DiffView`, with a staged/unstaged toggle — + * and **full** — `file` read, base64-decoded into highlighted text, markdown + * preview, or an image. Both loads run in parallel; like the web page, the + * viewer auto-falls to full mode when the diff is empty/failed or the file is + * an image, until the user picks a mode explicitly. + */ +class FileViewerViewModel( + private val sessionId: String, + private val path: String, + initialStaged: Boolean?, + initialMode: ViewerMode?, + focusLine: Int?, + private val gateway: FilesGateway, + private val scope: CoroutineScope, + private val strings: FileViewerStrings = FileViewerStrings(), +) { + private val stateFlow = MutableStateFlow( + FileViewerUiState( + path = path, + fileName = path.substringAfterLast('/').ifEmpty { path }, + mode = initialMode ?: ViewerMode.DIFF, + staged = initialStaged ?: false, + focusLine = focusLine, + ), + ) + val state: StateFlow = stateFlow.asStateFlow() + + /** Explicit mode choice (initial `mode` arg or a chip tap) disables auto-fallback. */ + private var modeChosen = initialMode != null + private var started = false + private var diffJob: Job? = null + + fun start() { + if (started) return + started = true + loadDiff() + loadContent() + } + + fun refresh() { + loadDiff() + loadContent() + } + + fun setMode(mode: ViewerMode) { + modeChosen = true + stateFlow.update { it.copy(mode = mode) } + } + + /** Staged/unstaged toggle: reloads the diff for the other side. */ + fun setStaged(staged: Boolean) { + if (stateFlow.value.staged == staged) return + stateFlow.update { it.copy(staged = staged) } + loadDiff() + } + + fun setMarkdownPreview(preview: Boolean) { + stateFlow.update { it.copy(markdownPreview = preview) } + } + + // ---------------------------------------------------------------- diff -- + + private fun loadDiff() { + diffJob?.cancel() + stateFlow.update { it.copy(diff = DiffUiState.Loading) } + diffJob = scope.launch { + val staged = stateFlow.value.staged + val diff = try { + val response = gateway.gitDiffFile(sessionId, path, staged) + when { + !response.success -> + DiffUiState.Failed(response.error ?: response.stderr ?: strings.loadDiffFailed) + response.stdout.isNullOrEmpty() -> DiffUiState.Empty + else -> { + val files = UnifiedDiffParser.parse(response.stdout.orEmpty()) + if (files.isEmpty()) DiffUiState.Empty else DiffUiState.Ready(files) + } + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + DiffUiState.Failed(e.message ?: strings.loadDiffFailed) + } + stateFlow.update { it.copy(diff = diff) } + autoSelectMode() + } + } + + // ------------------------------------------------------------- content -- + + private fun loadContent() { + scope.launch { + val content = try { + decodeContent(gateway.readFile(sessionId, path)) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + stateFlow.update { it.copy(content = FileContentUiState.Failed(e.message ?: strings.readFileFailed)) } + return@launch + } + stateFlow.update { + it.copy( + content = content.state, + sizeBytes = content.size, + modifiedAt = content.modified, + ) + } + autoSelectMode() + } + } + + private class DecodedContent(val state: FileContentUiState, val size: Long?, val modified: Long?) + + private fun decodeContent(response: FileReadResponse): DecodedContent { + if (!response.success) { + return DecodedContent( + FileContentUiState.Failed(response.error ?: strings.readFileFailed), + response.size, + response.modified, + ) + } + val base64 = response.content + if (base64.isNullOrEmpty()) { + return DecodedContent(FileContentUiState.Empty, response.size, response.modified) + } + + val bytes = try { + Base64.getMimeDecoder().decode(base64) + } catch (_: IllegalArgumentException) { + // Undecodable payload — treat like the web's failed decode: binary. + return DecodedContent(FileContentUiState.Binary, response.size, response.modified) + } + + val mime = imageMimeType(path) + if (mime != null) { + return DecodedContent(FileContentUiState.Image(bytes, mime), response.size, response.modified) + } + + val text = String(bytes, Charsets.UTF_8) + if (isBinaryContent(text)) { + return DecodedContent(FileContentUiState.Binary, response.size, response.modified) + } + if (text.isEmpty()) { + return DecodedContent(FileContentUiState.Empty, response.size, response.modified) + } + return DecodedContent( + FileContentUiState.Text( + text = text, + language = fileExtension(path), + isMarkdown = isMarkdownFile(path), + ), + response.size, + response.modified, + ) + } + + // ---------------------------------------------------------------- mode -- + + /** + * Web `file.tsx` effect: images always open full; an empty or failed diff + * falls back to full. Skipped once the user (or the route) chose a mode. + */ + private fun autoSelectMode() { + if (modeChosen) { + // Images have no text diff worth showing even when explicitly + // requested via mode=diff from a stale link. + return + } + val current = stateFlow.value + val shouldShowFile = current.content is FileContentUiState.Image || + current.diff is DiffUiState.Empty || + current.diff is DiffUiState.Failed + if (shouldShowFile && current.mode == ViewerMode.DIFF) { + stateFlow.update { it.copy(mode = ViewerMode.FILE) } + } + } + + companion object { + /** Web `IMAGE_MIME_BY_EXTENSION` (`file.tsx`). */ + private val IMAGE_MIME_BY_EXTENSION = mapOf( + "apng" to "image/apng", + "avif" to "image/avif", + "bmp" to "image/bmp", + "gif" to "image/gif", + "ico" to "image/x-icon", + "jpeg" to "image/jpeg", + "jpg" to "image/jpeg", + "png" to "image/png", + "svg" to "image/svg+xml", + "tif" to "image/tiff", + "tiff" to "image/tiff", + "webp" to "image/webp", + ) + + fun fileExtension(path: String): String? { + val parts = path.split(".") + if (parts.size <= 1) return null + return parts.last().lowercase().ifEmpty { null } + } + + fun imageMimeType(path: String): String? = + fileExtension(path)?.let { IMAGE_MIME_BY_EXTENSION[it] } + + /** Web `isMarkdownFile` (`file-markdown-preview.ts`): md / mdx only. */ + fun isMarkdownFile(path: String): Boolean { + val ext = fileExtension(path) + return ext == "md" || ext == "mdx" + } + + /** Web `isBinaryContent`: NUL, or > 10% control chars (excluding \t \n \r). */ + fun isBinaryContent(content: String): Boolean { + if (content.isEmpty()) return false + if ('\u0000' in content) return true + val nonPrintable = content.count { it.code < 32 && it.code != 9 && it.code != 10 && it.code != 13 } + return nonPrintable.toDouble() / content.length > 0.1 + } + } +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/files/FilesGateway.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/files/FilesGateway.kt new file mode 100644 index 0000000000..c45644c90a --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/files/FilesGateway.kt @@ -0,0 +1,44 @@ +package app.hapi.companion.feature.files + +import app.hapi.data.api.HapiApi +import app.hapi.protocol.wire.FileReadResponse +import app.hapi.protocol.wire.FileSearchResponse +import app.hapi.protocol.wire.GitCommandResponse +import app.hapi.protocol.wire.ListDirectoryResponse + +/** + * The git/files REST surface the files feature consumes — a seam over + * [HapiApi] so [FilesViewModel]/[FileViewerViewModel] tests run against fakes + * (same pattern as `NewSessionGateway`). All six endpoints are RPC-wrapped: + * check `success` on the body; transport failures throw. + */ +interface FilesGateway { + suspend fun gitStatus(sessionId: String): GitCommandResponse + suspend fun gitDiffNumstat(sessionId: String, staged: Boolean): GitCommandResponse + suspend fun gitDiffFile(sessionId: String, path: String, staged: Boolean?): GitCommandResponse + suspend fun readFile(sessionId: String, path: String): FileReadResponse + suspend fun searchFiles(sessionId: String, query: String, limit: Int): FileSearchResponse + + /** [path] is relative to the session root; null lists the root itself. */ + suspend fun listDirectory(sessionId: String, path: String?): ListDirectoryResponse +} + +class ApiFilesGateway(private val api: HapiApi) : FilesGateway { + override suspend fun gitStatus(sessionId: String): GitCommandResponse = + api.getGitStatus(sessionId) + + override suspend fun gitDiffNumstat(sessionId: String, staged: Boolean): GitCommandResponse = + api.getGitDiffNumstat(sessionId, staged) + + override suspend fun gitDiffFile(sessionId: String, path: String, staged: Boolean?): GitCommandResponse = + api.getGitDiffFile(sessionId, path, staged) + + override suspend fun readFile(sessionId: String, path: String): FileReadResponse = + api.readSessionFile(sessionId, path) + + override suspend fun searchFiles(sessionId: String, query: String, limit: Int): FileSearchResponse = + api.searchSessionFiles(sessionId, query, limit) + + override suspend fun listDirectory(sessionId: String, path: String?): ListDirectoryResponse = + api.listSessionDirectory(sessionId, path) +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/files/FilesScreen.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/files/FilesScreen.kt new file mode 100644 index 0000000000..65cb24f5fa --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/files/FilesScreen.kt @@ -0,0 +1,495 @@ +package app.hapi.companion.feature.files + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import app.hapi.companion.R +import app.hapi.companion.ui.theme.hapi +import app.hapi.protocol.git.GitFileStatus +import app.hapi.protocol.wire.FileSearchItem + +/** + * Session files browser (`chat/{sessionId}/files`), the Android take on web + * `files.tsx`: **Changes** (branch header + staged/unstaged sections with + * status letters and ±counts), **Browse** (lazily expanded directory tree, + * dirs-first sort, hidden-file toggle), **Search** (debounced ripgrep query). + * Rows open the file viewer — Changes rows carry their staged side so the + * viewer opens on the right diff. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FilesScreen( + viewModel: FilesViewModel, + onBack: () -> Unit, + onOpenFile: (path: String, staged: Boolean?) -> Unit, + modifier: Modifier = Modifier, +) { + DisposableEffect(viewModel) { + viewModel.start() + onDispose { } + } + + val changes by viewModel.changes.collectAsState() + val browse by viewModel.browse.collectAsState() + val search by viewModel.search.collectAsState() + var tab by rememberSaveable { mutableIntStateOf(0) } + + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + TopAppBar( + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.files_back)) + } + }, + title = { Text(stringResource(R.string.files_title)) }, + actions = { + IconButton( + onClick = { + when (tab) { + 0 -> viewModel.refreshChanges() + 1 -> viewModel.refreshBrowse() + else -> viewModel.refreshSearch() + } + }, + ) { + Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.files_refresh)) + } + }, + ) + }, + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding), + ) { + TabRow(selectedTabIndex = tab) { + Tab(selected = tab == 0, onClick = { tab = 0 }, text = { Text(stringResource(R.string.files_tab_changes)) }) + Tab(selected = tab == 1, onClick = { tab = 1 }, text = { Text(stringResource(R.string.files_tab_browse)) }) + Tab(selected = tab == 2, onClick = { tab = 2 }, text = { Text(stringResource(R.string.files_tab_search)) }) + } + when (tab) { + 0 -> ChangesTab(changes, onOpenFile) + 1 -> BrowseTab( + browse, + onToggleDirectory = viewModel::toggleDirectory, + onToggleHidden = viewModel::setShowHidden, + onOpenFile = { path -> onOpenFile(path, null) }, + ) + else -> SearchTab( + search, + onQueryChange = viewModel::setSearchQuery, + onOpenFile = { path -> onOpenFile(path, null) }, + ) + } + } + } +} + +// ------------------------------------------------------------ Changes tab -- + +@Composable +private fun ChangesTab( + state: ChangesUiState, + onOpenFile: (path: String, staged: Boolean?) -> Unit, +) { + val colors = MaterialTheme.hapi + + Column(modifier = Modifier.fillMaxSize()) { + state.error?.let { ErrorBanner(it) } + + when { + state.loading && state.status == null -> CenteredProgress() + state.status == null -> CenteredHint(stringResource(R.string.files_git_unavailable)) + else -> { + val status = state.status + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + GitBranchGlyph, + contentDescription = null, + tint = colors.hint, + modifier = Modifier.size(16.dp), + ) + Column { + Text( + text = status.branch ?: stringResource(R.string.files_detached_head), + fontWeight = FontWeight.SemiBold, + style = MaterialTheme.typography.bodyMedium, + ) + Text( + text = stringResource( + R.string.files_staged_unstaged, + status.totalStaged, + status.totalUnstaged, + ), + fontSize = 12.sp, + color = colors.hint, + ) + } + } + + if (status.stagedFiles.isEmpty() && status.unstagedFiles.isEmpty()) { + CenteredHint(stringResource(R.string.files_no_changes)) + } else { + LazyColumn(modifier = Modifier.fillMaxSize()) { + if (status.stagedFiles.isNotEmpty()) { + item(key = "staged-header") { + SectionHeader(stringResource(R.string.files_staged_header, status.stagedFiles.size)) + } + items( + status.stagedFiles.size, + key = { "staged-${status.stagedFiles[it].fullPath}-$it" }, + ) { index -> + val file = status.stagedFiles[index] + GitFileRow(file) { onOpenFile(file.fullPath, true) } + } + } + if (status.unstagedFiles.isNotEmpty()) { + item(key = "unstaged-header") { + SectionHeader(stringResource(R.string.files_unstaged_header, status.unstagedFiles.size)) + } + items( + status.unstagedFiles.size, + key = { "unstaged-${status.unstagedFiles[it].fullPath}-$it" }, + ) { index -> + val file = status.unstagedFiles[index] + GitFileRow(file) { onOpenFile(file.fullPath, false) } + } + } + } + } + } + } + } +} + +@Composable +private fun SectionHeader(text: String) { + Text( + text = text, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.hapi.hint, + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.hapi.codeHeaderBackground) + .padding(horizontal = 16.dp, vertical = 6.dp), + ) +} + +@Composable +private fun GitFileRow(file: GitFileStatus, onClick: () -> Unit) { + val colors = MaterialTheme.hapi + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = file.fileName, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + val subtitle = file.oldPath?.let { "$it → ${file.fullPath}" } + ?: file.filePath.ifEmpty { stringResource(R.string.files_project_root) } + Text( + text = subtitle, + fontSize = 12.sp, + color = colors.hint, + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis, + ) + } + if (file.linesAdded > 0 || file.linesRemoved > 0) { + Text( + text = buildString { + if (file.linesAdded > 0) append("+${file.linesAdded}") + if (file.linesAdded > 0 && file.linesRemoved > 0) append(' ') + if (file.linesRemoved > 0) append("-${file.linesRemoved}") + }, + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + color = colors.hint, + ) + } + StatusBadge(file) + } +} + +@Composable +private fun StatusBadge(file: GitFileStatus) { + val color = statusColor(file.status, MaterialTheme.hapi.isDark) + Text( + text = statusLetter(file.status), + fontSize = 10.sp, + fontWeight = FontWeight.SemiBold, + color = color, + modifier = Modifier + .border(1.dp, color, RoundedCornerShape(4.dp)) + .padding(horizontal = 6.dp, vertical = 2.dp), + ) +} + +// ------------------------------------------------------------- Browse tab -- + +@Composable +private fun BrowseTab( + state: BrowseUiState, + onToggleDirectory: (String) -> Unit, + onToggleHidden: (Boolean) -> Unit, + onOpenFile: (path: String) -> Unit, +) { + val colors = MaterialTheme.hapi + + Column(modifier = Modifier.fillMaxSize()) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onToggleHidden(!state.showHidden) } + .padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox(checked = state.showHidden, onCheckedChange = onToggleHidden) + Text(stringResource(R.string.files_show_hidden), fontSize = 13.sp, color = colors.hint) + } + + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(state.rows.size) { index -> + when (val row = state.rows[index]) { + is BrowseRow.Dir -> DirectoryRow(row, onToggleDirectory) + is BrowseRow.File -> FileRow(row, onOpenFile) + is BrowseRow.Loading -> Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = rowIndent(row.depth) + 16.dp, top = 10.dp, bottom = 10.dp), + ) { + CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) + } + is BrowseRow.Error -> Text( + text = row.message, + fontSize = 12.sp, + color = MaterialTheme.colorScheme.error, + modifier = Modifier + .fillMaxWidth() + .padding(start = rowIndent(row.depth) + 16.dp, top = 8.dp, bottom = 8.dp, end = 16.dp), + ) + } + } + } + } +} + +private fun rowIndent(depth: Int) = (depth * 16).dp + +@Composable +private fun DirectoryRow(row: BrowseRow.Dir, onToggle: (String) -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onToggle(row.path) } + .padding(start = rowIndent(row.depth) + 8.dp, end = 16.dp, top = 8.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon( + if (row.expanded) Icons.Filled.KeyboardArrowDown else Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = stringResource(if (row.expanded) R.string.files_collapse else R.string.files_expand), + tint = MaterialTheme.hapi.hint, + modifier = Modifier.size(18.dp), + ) + Icon( + FolderGlyph, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp), + ) + Text( + text = row.name, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun FileRow(row: BrowseRow.File, onOpenFile: (String) -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onOpenFile(row.path) } + // Files align with sibling directory names (chevron width offset). + .padding(start = rowIndent(row.depth) + 30.dp, end = 16.dp, top = 8.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = row.name, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + formatFileMetadata(row.size, row.modified)?.let { + Text(text = it, fontSize = 11.sp, color = MaterialTheme.hapi.hint) + } + } + } +} + +// ------------------------------------------------------------- Search tab -- + +@Composable +private fun SearchTab( + state: SearchUiState, + onQueryChange: (String) -> Unit, + onOpenFile: (path: String) -> Unit, +) { + val colors = MaterialTheme.hapi + + Column(modifier = Modifier.fillMaxSize()) { + OutlinedTextField( + value = state.query, + onValueChange = onQueryChange, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + placeholder = { Text(stringResource(R.string.files_search_placeholder)) }, + leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) }, + singleLine = true, + ) + + when { + state.query.isBlank() -> CenteredHint(stringResource(R.string.files_search_hint)) + state.loading -> CenteredProgress() + state.error != null -> ErrorBanner(state.error) + state.searched && state.results.isEmpty() -> CenteredHint(stringResource(R.string.files_search_no_match)) + else -> LazyColumn(modifier = Modifier.fillMaxSize()) { + items(state.results, key = { it.fullPath }) { item -> + SearchResultRow(item) { onOpenFile(item.fullPath) } + } + } + } + } +} + +@Composable +private fun SearchResultRow(item: FileSearchItem, onClick: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = item.fullPath, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis, + ) + formatFileMetadata(item.size, item.modified)?.let { + Text(text = it, fontSize = 11.sp, color = MaterialTheme.hapi.hint) + } + } + } +} + +// ------------------------------------------------------------------ misc -- + +@Composable +private fun ErrorBanner(message: String) { + Text( + text = message, + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.errorContainer) + .padding(horizontal = 16.dp, vertical = 8.dp), + ) +} + +@Composable +private fun CenteredProgress() { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(top = 48.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } +} + +@Composable +private fun CenteredHint(text: String) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 48.dp), + contentAlignment = Alignment.Center, + ) { + Text(text = text, fontSize = 13.sp, color = MaterialTheme.hapi.hint) + } +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/files/FilesUi.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/files/FilesUi.kt new file mode 100644 index 0000000000..318b148e94 --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/files/FilesUi.kt @@ -0,0 +1,98 @@ +package app.hapi.companion.feature.files + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.addPathNodes +import androidx.compose.ui.unit.dp +import app.hapi.protocol.git.GitFileChange +import java.text.DateFormat +import java.util.Date +import java.util.Locale +import kotlin.math.roundToLong + +// Shared bits of the files feature UI: metadata formatting (web +// `file-metadata.ts`), status-badge palette, and the folder glyph the chat +// top bar and Browse tab share (the material core icon set has no folder). + +/** `1.2 KB` / `640 B`; null when size is unknown (web `formatFileSize`). */ +internal fun formatFileSize(bytes: Long?): String? { + if (bytes == null || bytes < 0) return null + if (bytes < 1024) return "$bytes B" + val units = arrayOf("KB", "MB", "GB", "TB") + var value = bytes.toDouble() + var unit = -1 + while (value >= 1024 && unit < units.size - 1) { + value /= 1024 + unit += 1 + } + val formatted = if (value >= 10) { + value.roundToLong().toString() + } else { + String.format(Locale.US, "%.1f", value).removeSuffix(".0") + } + return "$formatted ${units[unit]}" +} + +/** `12/31/2026, 10:03 · 1.2 KB`-style joined metadata line (web `formatFileMetadata`). */ +internal fun formatFileMetadata(size: Long?, modified: Long?): String? { + val time = modified?.let { + DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT).format(Date(it)) + } + val parts = listOfNotNull(time, formatFileSize(size)) + return parts.joinToString(" · ").ifEmpty { null } +} + +/** Single status letter of the Changes list (web `StatusBadge`). */ +internal fun statusLetter(status: GitFileChange): String = when (status) { + GitFileChange.ADDED -> "A" + GitFileChange.DELETED -> "D" + GitFileChange.RENAMED -> "R" + GitFileChange.UNTRACKED -> "?" + GitFileChange.CONFLICTED -> "U" + GitFileChange.MODIFIED -> "M" +} + +/** Badge tint per status, tuned per theme (web `--app-git-*-color` vars). */ +internal fun statusColor(status: GitFileChange, dark: Boolean): Color = when (status) { + GitFileChange.ADDED -> if (dark) Color(0xFF4CC38A) else Color(0xFF1A7F37) + GitFileChange.DELETED, GitFileChange.CONFLICTED -> if (dark) Color(0xFFF47067) else Color(0xFFCF222E) + GitFileChange.RENAMED -> if (dark) Color(0xFFDBAB0A) else Color(0xFF9A6700) + GitFileChange.UNTRACKED -> if (dark) Color(0xFF8E8E93) else Color(0xFF6B7280) + GitFileChange.MODIFIED -> if (dark) Color(0xFF539BF5) else Color(0xFF0969DA) +} + +private fun strokeIcon(name: String, pathData: String, strokeWidth: Float = 1.8f): ImageVector = + ImageVector.Builder( + name = name, + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, + ).apply { + addPath( + pathData = addPathNodes(pathData), + fill = null, + // Any opaque stroke works: Icon() recolors via ColorFilter tint. + stroke = SolidColor(Color.Black), + strokeLineWidth = strokeWidth, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round, + ) + }.build() + +/** Folder outline (the web `FolderIcon` path). */ +internal val FolderGlyph: ImageVector by lazy { + strokeIcon("HapiFolder", "M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z", 1.6f) +} + +/** Git branch glyph for the Changes header (the web `GitBranchIcon` paths). */ +internal val GitBranchGlyph: ImageVector by lazy { + strokeIcon( + "HapiGitBranch", + "M6 3 L6 15 M6 15 a3 3 0 1 0 0.0001 0 M18 3 a3 3 0 1 0 0.0001 0 M18 9 a9 9 0 0 1 -9 9", + 2f, + ) +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/files/FilesViewModel.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/files/FilesViewModel.kt new file mode 100644 index 0000000000..22f431e173 --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/files/FilesViewModel.kt @@ -0,0 +1,337 @@ +package app.hapi.companion.feature.files + +import app.hapi.protocol.git.GitStatusFiles +import app.hapi.protocol.git.GitStatusParser +import app.hapi.protocol.wire.DirectoryEntry +import app.hapi.protocol.wire.FileSearchItem +import app.hapi.protocol.wire.GitCommandResponse +import kotlin.coroutines.cancellation.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +// ------------------------------------------------------------- UI models -- + +/** Changes tab (web `useGitStatusFiles` + `files.tsx` Changes list). */ +data class ChangesUiState( + val loading: Boolean = true, + /** null after load ⇒ git unavailable for this session (not a repo / no path). */ + val status: GitStatusFiles? = null, + /** Banner text: status failure, or partial numstat failures. */ + val error: String? = null, +) + +/** One row of the flattened Browse tree ([FilesViewModel.browse]). */ +sealed interface BrowseRow { + val depth: Int + + data class Dir( + /** Session-root-relative path (`src/app`). */ + val path: String, + val name: String, + override val depth: Int, + val expanded: Boolean, + ) : BrowseRow + + data class File( + val path: String, + val name: String, + override val depth: Int, + val size: Long?, + val modified: Long?, + ) : BrowseRow + + /** Placeholder while a directory listing is in flight. */ + data class Loading(val parentPath: String, override val depth: Int) : BrowseRow + + /** Inline listing failure for one directory (web `DirectoryErrorRow`). */ + data class Error(val parentPath: String, override val depth: Int, val message: String) : BrowseRow +} + +data class BrowseUiState( + val rows: List = emptyList(), + val showHidden: Boolean = false, +) + +/** Search tab (debounced `GET /files?query=`). */ +data class SearchUiState( + val query: String = "", + val loading: Boolean = false, + val results: List = emptyList(), + val error: String? = null, + /** True once a search for the current query completed (drives the empty state). */ + val searched: Boolean = false, +) + +/** + * Fallback strings the files ViewModel needs (B-M5a Strings seam): defaults + * are the pre-i18n English (JVM tests construct without arguments); production + * passes resource-resolved values from the Navigation holder. The two diff + * banners are `%1$s`-formatted with the failure detail. + */ +class FilesStrings( + val gitStatusUnavailable: String = "Git status unavailable", + val unstagedDiffUnavailable: String = "Unstaged diff unavailable: %1\$s", + val stagedDiffUnavailable: String = "Staged diff unavailable: %1\$s", + val unknownError: String = "unknown error", + val listDirectoryFailed: String = "Failed to list directory", + val searchFailed: String = "Failed to search files", +) + +/** + * Files screen state: three independent tabs over the session's git/files + * endpoints. Changes ports `useGitStatusFiles` (status + both numstat sides + * merged in `:core:protocol`'s `GitStatusParser.buildGitStatusFiles`); Browse + * is a lazily-expanded directory tree flattened to rows (dirs-first name sort + * like web `directory-sort.ts`, plus an Android-only hidden-file toggle); + * Search debounces the ripgrep-backed `/files` query. + */ +class FilesViewModel( + private val sessionId: String, + private val gateway: FilesGateway, + private val scope: CoroutineScope, + private val strings: FilesStrings = FilesStrings(), + private val searchDebounceMs: Long = SEARCH_DEBOUNCE_MS, +) { + private val changesState = MutableStateFlow(ChangesUiState()) + val changes: StateFlow = changesState.asStateFlow() + + private val browseState = MutableStateFlow(BrowseUiState()) + val browse: StateFlow = browseState.asStateFlow() + + private val searchState = MutableStateFlow(SearchUiState()) + val search: StateFlow = searchState.asStateFlow() + + /** null = never requested; entries null while loading. */ + private data class DirNode(val entries: List? = null, val error: String? = null) { + val loading: Boolean get() = entries == null && error == null + } + + private val nodes = MutableStateFlow>(emptyMap()) + private val expanded = MutableStateFlow>(emptySet()) + private val queryInput = MutableStateFlow("") + private var started = false + + fun start() { + if (started) return + started = true + refreshChanges() + loadDirectory(ROOT) + scope.launch { + queryInput.collectLatest { query -> + if (query.isBlank()) { + searchState.value = SearchUiState(query = query) + return@collectLatest + } + delay(searchDebounceMs) + runSearch(query) + } + } + } + + // ------------------------------------------------------------- changes -- + + fun refreshChanges() { + scope.launch { loadChanges() } + } + + private suspend fun loadChanges() { + changesState.update { it.copy(loading = true, error = null) } + + val statusResult = try { + gateway.gitStatus(sessionId) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + changesState.value = ChangesUiState( + loading = false, + status = null, + error = e.message ?: strings.gitStatusUnavailable, + ) + return + } + if (!statusResult.success) { + changesState.value = ChangesUiState( + loading = false, + status = null, + error = statusResult.error ?: statusResult.stderr ?: strings.gitStatusUnavailable, + ) + return + } + + // Both numstat sides in parallel; a failed side degrades to zero + // counts plus a banner note, never a failed tab (web parity). + val (unstagedResult, stagedResult) = coroutineScope { + val unstaged = async { runCatching { gateway.gitDiffNumstat(sessionId, staged = false) } } + val staged = async { runCatching { gateway.gitDiffNumstat(sessionId, staged = true) } } + unstaged.await() to staged.await() + } + + val unstaged = unstagedResult.getOrNull() + val staged = stagedResult.getOrNull() + val status = GitStatusParser.buildGitStatusFiles( + statusOutput = statusResult.stdout.orEmpty(), + unstagedDiffOutput = if (unstaged?.success == true) unstaged.stdout.orEmpty() else "", + stagedDiffOutput = if (staged?.success == true) staged.stdout.orEmpty() else "", + ) + + val errors = buildList { + if (unstaged?.success != true) { + add(strings.unstagedDiffUnavailable.format(describeNumstatFailure(unstaged, unstagedResult.exceptionOrNull()))) + } + if (staged?.success != true) { + add(strings.stagedDiffUnavailable.format(describeNumstatFailure(staged, stagedResult.exceptionOrNull()))) + } + } + + changesState.value = ChangesUiState( + loading = false, + status = status, + error = errors.joinToString(" ").ifEmpty { null }, + ) + } + + private fun describeNumstatFailure( + result: GitCommandResponse?, + exception: Throwable?, + ): String = result?.error ?: result?.stderr ?: exception?.message ?: strings.unknownError + + // -------------------------------------------------------------- browse -- + + fun toggleDirectory(path: String) { + val wasExpanded = path in expanded.value + expanded.update { if (wasExpanded) it - path else it + path } + if (!wasExpanded && nodes.value[path] == null) { + loadDirectory(path) + } else { + rebuildBrowse() + } + } + + fun setShowHidden(showHidden: Boolean) { + browseState.update { it.copy(showHidden = showHidden) } + rebuildBrowse() + } + + /** Re-lists the root and every expanded directory. */ + fun refreshBrowse() { + loadDirectory(ROOT) + for (path in expanded.value) loadDirectory(path) + } + + private fun loadDirectory(path: String) { + nodes.update { it + (path to DirNode()) } + rebuildBrowse() + scope.launch { + val node = try { + val response = gateway.listDirectory(sessionId, path.ifEmpty { null }) + if (response.success) { + DirNode(entries = response.entries.orEmpty()) + } else { + DirNode(error = response.error ?: strings.listDirectoryFailed) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + DirNode(error = e.message ?: strings.listDirectoryFailed) + } + nodes.update { it + (path to node) } + rebuildBrowse() + } + } + + private fun rebuildBrowse() { + val rows = mutableListOf() + appendChildren(ROOT, 0, rows) + browseState.update { it.copy(rows = rows) } + } + + private fun appendChildren(path: String, depth: Int, out: MutableList) { + val node = nodes.value[path] + when { + node == null || node.loading -> out += BrowseRow.Loading(path, depth) + node.error != null -> out += BrowseRow.Error(path, depth, node.error) + else -> { + val showHidden = browseState.value.showHidden + val visible = node.entries.orEmpty() + .filter { showHidden || !it.name.startsWith(".") } + .sortedWith(DIRS_FIRST_BY_NAME) + for (entry in visible) { + val childPath = if (path.isEmpty()) entry.name else "$path/${entry.name}" + when (entry.type) { + "directory" -> { + val isExpanded = childPath in expanded.value + out += BrowseRow.Dir(childPath, entry.name, depth, isExpanded) + if (isExpanded) appendChildren(childPath, depth + 1, out) + } + "file" -> out += BrowseRow.File(childPath, entry.name, depth, entry.size, entry.modified) + // 'other' entries (sockets, links, …) are dropped, like the web tree. + } + } + } + } + } + + // -------------------------------------------------------------- search -- + + fun setSearchQuery(query: String) { + searchState.update { it.copy(query = query) } + queryInput.value = query + } + + fun refreshSearch() { + val query = queryInput.value + if (query.isBlank()) return + scope.launch { runSearch(query) } + } + + private suspend fun runSearch(query: String) { + searchState.update { it.copy(loading = true, error = null) } + try { + val response = gateway.searchFiles(sessionId, query, SEARCH_LIMIT) + searchState.update { + if (response.success) { + it.copy(loading = false, results = response.files.orEmpty(), error = null, searched = true) + } else { + it.copy( + loading = false, + results = emptyList(), + error = response.error ?: strings.searchFailed, + searched = true, + ) + } + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + searchState.update { + it.copy( + loading = false, + results = emptyList(), + error = e.message ?: strings.searchFailed, + searched = true, + ) + } + } + } + + private companion object { + const val ROOT = "" + const val SEARCH_DEBOUNCE_MS = 300L + + /** Web default limit (`useSessionFileSearch`). */ + const val SEARCH_LIMIT = 200 + + /** Dirs first, then case-insensitive name — web `sortDirectoryEntries` default. */ + val DIRS_FIRST_BY_NAME: Comparator = + compareBy { it.type != "directory" } + .thenBy(String.CASE_INSENSITIVE_ORDER) { it.name } + } +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/home/HomeScreen.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/home/HomeScreen.kt new file mode 100644 index 0000000000..948eb0b6a1 --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/home/HomeScreen.kt @@ -0,0 +1,217 @@ +package app.hapi.companion.feature.home + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import app.hapi.companion.R +import app.hapi.companion.feature.sessions.SessionListScreen +import app.hapi.companion.feature.sessions.SessionListViewModel + +/** + * Home = the session list (B-M2b) under a top bar that keeps the hub chores + * reachable: overflow menu with hub switcher, pair-another and sign-out + * (the pre-M2b placeholder screen folded into a menu). + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HomeScreen( + viewModel: SessionListViewModel, + activeHubUrl: String, + pairedHubs: List, + onSwitchHub: (String) -> Unit, + onPairAnotherHub: () -> Unit, + onSignOut: () -> Unit, + onOpenSession: (sessionId: String) -> Unit, + /** "+" FAB on the session list → new-session form (B-M3d). */ + onNewSession: (() -> Unit)? = null, + /** Overflow menu → settings scaffold (B-M4e). */ + onOpenSettings: (() -> Unit)? = null, +) { + var menuOpen by rememberSaveable { mutableStateOf(false) } + var showSwitcher by rememberSaveable { mutableStateOf(false) } + var showSignOutConfirm by rememberSaveable { mutableStateOf(false) } + + Scaffold( + topBar = { + TopAppBar( + title = { + Column { + Text( + text = stringResource(R.string.app_name), + style = MaterialTheme.typography.titleLarge, + ) + Text( + text = activeHubUrl, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + }, + actions = { + IconButton(onClick = { menuOpen = true }) { + Icon(Icons.Default.MoreVert, contentDescription = stringResource(R.string.home_menu)) + } + DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + if (onOpenSettings != null) { + DropdownMenuItem( + text = { Text(stringResource(R.string.home_settings)) }, + onClick = { + menuOpen = false + onOpenSettings() + }, + ) + } + DropdownMenuItem( + text = { Text(stringResource(R.string.home_switch_hub)) }, + onClick = { + menuOpen = false + showSwitcher = true + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.home_pair_another)) }, + onClick = { + menuOpen = false + onPairAnotherHub() + }, + ) + DropdownMenuItem( + text = { + Text( + text = stringResource(R.string.home_sign_out), + color = MaterialTheme.colorScheme.error, + ) + }, + onClick = { + menuOpen = false + showSignOutConfirm = true + }, + ) + } + }, + ) + }, + ) { padding -> + SessionListScreen( + viewModel = viewModel, + onOpenSession = onOpenSession, + modifier = Modifier + .fillMaxSize() + .padding(padding), + onNewSession = onNewSession, + ) + } + + if (showSwitcher) { + HubSwitcherDialog( + activeHubUrl = activeHubUrl, + pairedHubs = pairedHubs, + onSwitchHub = { hub -> + showSwitcher = false + if (hub != activeHubUrl) onSwitchHub(hub) + }, + onPairAnotherHub = { + showSwitcher = false + onPairAnotherHub() + }, + onDismiss = { showSwitcher = false }, + ) + } + + if (showSignOutConfirm) { + AlertDialog( + onDismissRequest = { showSignOutConfirm = false }, + title = { Text(stringResource(R.string.home_sign_out)) }, + text = { Text(stringResource(R.string.home_sign_out_message)) }, + confirmButton = { + TextButton( + onClick = { + showSignOutConfirm = false + onSignOut() + }, + ) { + Text( + text = stringResource(R.string.home_sign_out), + color = MaterialTheme.colorScheme.error, + ) + } + }, + dismissButton = { + TextButton(onClick = { showSignOutConfirm = false }) { + Text(stringResource(R.string.home_cancel)) + } + }, + ) + } +} + +@Composable +private fun HubSwitcherDialog( + activeHubUrl: String, + pairedHubs: List, + onSwitchHub: (String) -> Unit, + onPairAnotherHub: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.home_switch_hub)) }, + text = { + Column { + pairedHubs.forEach { hub -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton(selected = hub == activeHubUrl, onClick = { onSwitchHub(hub) }) + Text( + text = hub, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(start = 4.dp), + ) + } + } + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + TextButton(onClick = onPairAnotherHub) { + Text(stringResource(R.string.home_pair_another)) + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.home_cancel)) + } + }, + ) +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionCatalogs.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionCatalogs.kt new file mode 100644 index 0000000000..a8d83829e1 --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionCatalogs.kt @@ -0,0 +1,48 @@ +package app.hapi.companion.feature.newsession + +/** A `(wire value, display label)` pair for the option pickers. */ +data class OptionItem(val value: String, val label: String) + +/** + * Static option catalogs for the create form — data ports of + * `shared/src/models.ts` (`CLAUDE_MODEL_LABELS`), `shared/src/effort.ts` + * (`CLAUDE_EFFORT_LABELS`) and the web `CODEX_REASONING_EFFORT_OPTIONS` + * (`web/src/components/NewSession/types.ts`). Kept in the feature (not + * `:core:protocol`'s catalog package) because they are create-form option + * lists, not wire-format contracts. + */ +object NewSessionCatalogs { + + /** `'auto'` sentinel rows use the web's "Default" label. */ + val CLAUDE_MODELS: List = listOf( + OptionItem("auto", "Default"), + OptionItem("sonnet", "Sonnet"), + OptionItem("sonnet[1m]", "Sonnet 1M"), + OptionItem("opus", "Opus"), + OptionItem("opus[1m]", "Opus 1M"), + OptionItem("fable", "Fable"), + OptionItem("fable[1m]", "Fable 1M"), + ) + + val CLAUDE_EFFORTS: List = listOf( + OptionItem("auto", "Auto"), + OptionItem("low", "Low"), + OptionItem("medium", "Medium"), + OptionItem("high", "High"), + OptionItem("xhigh", "XHigh"), + OptionItem("max", "Max"), + ) + + /** Static codex fallback when the model row advertises no efforts (web drops `max` for codex). */ + val CODEX_REASONING_EFFORTS: List = listOf( + OptionItem("default", "Default"), + OptionItem("low", "Low"), + OptionItem("medium", "Medium"), + OptionItem("high", "High"), + OptionItem("xhigh", "XHigh"), + ) + + /** Capitalized label for a server-advertised effort id (`high` → `High`). */ + fun effortLabel(value: String): String = + value.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() } +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionForm.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionForm.kt new file mode 100644 index 0000000000..30d70981c3 --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionForm.kt @@ -0,0 +1,258 @@ +package app.hapi.companion.feature.newsession + +import app.hapi.protocol.catalog.AgentFlavor +import app.hapi.protocol.catalog.PermissionModes +import app.hapi.protocol.wire.CodexModelSummary +import app.hapi.protocol.wire.SpawnSessionRequest +import kotlinx.serialization.Serializable + +/** + * New-session form model + the pure mapping/validation logic around it. + * Everything here is Android-free so JVM tests can assert the exact spawn + * body against `SpawnSessionRequestSchema`. Web reference: + * `web/src/components/NewSession/index.tsx` (`handleCreate`). + */ +@Serializable +data class NewSessionForm( + val machineId: String? = null, + val directory: String = "", + /** Flavor id from [AgentFlavor.CREATABLE]. */ + val agent: String = "claude", + /** `'auto'` = no explicit model (claude presets / codex catalog ids). */ + val model: String = "auto", + /** Claude launch effort; `'auto'` = omit. */ + val effort: String = "auto", + /** Codex reasoning effort; `'default'` = omit. */ + val modelReasoningEffort: String = "default", + /** Native permission mode for grok + codex-family flavors. */ + val permissionMode: String = "default", + /** HAPI YOLO preference for the remaining flavors (claude/agy/cursor/pi). */ + val yolo: Boolean = false, + /** `'simple' | 'worktree'`. */ + val sessionType: String = SESSION_TYPE_SIMPLE, + val worktreeName: String = "", + /** `'standard' | 'fast'` (codex; only sent while the fast tier is visible). */ + val serviceTier: String = "standard", + /** `'default' | 'plan'` (codex). */ + val collaborationMode: String = "default", + /** `'interactive' | 'plan' | 'autopilot'` (copilot; always sent for copilot). */ + val copilotAgentMode: String = "interactive", +) { + val trimmedDirectory: String get() = directory.trim() +} + +const val SESSION_TYPE_SIMPLE = "simple" +const val SESSION_TYPE_WORKTREE = "worktree" + +object NewSessionLogic { + + /** + * Flavors sharing the codex-style native permission select + * (`web/src/lib/codexFamilyPermissionAgents.ts`). Gemini is listed for + * completeness though it is not creatable. + */ + val CODEX_FAMILY_PERMISSION_AGENTS = setOf("codex", "gemini", "kimi", "copilot", "opencode") + + fun usesCodexFamilyPermissionModes(flavor: String?): Boolean = + flavor in CODEX_FAMILY_PERMISSION_AGENTS + + /** Flavors whose permission control is the native-mode select. */ + fun usesNativePermissionSelect(flavor: String?): Boolean = + flavor == "grok" || usesCodexFamilyPermissionModes(flavor) + + /** + * Exact spawn body (`POST /api/machines/:id/spawn`), field-for-field port + * of the web `handleCreate` mapping: + * - `model`/`effort` only for flavors whose picker exists in this v1 + * (claude static list, codex machine catalog; others send no model); + * - `yolo` for non-grok/non-codex-family flavors — **including `false`**; + * - `permissionMode` for grok + codex-family — including `'default'`; + * - `sessionType` always; `worktreeName` only for worktree and non-blank; + * - `serviceTier` only while the codex fast tier is visible (then also + * `'standard'`); `collaborationMode` only when not `'default'`; + * - `copilotAgentMode` always for copilot; + * - `startingMode` omitted = the runner's `'remote'` default (v1 fixes + * remote; pty is deferred, matching the web create form). + */ + fun buildSpawnRequest(form: NewSessionForm, codexFastTierVisible: Boolean): SpawnSessionRequest { + val agent = form.agent + val codexFamily = usesCodexFamilyPermissionModes(agent) + val isGrok = agent == "grok" + val resolvedModel = when { + // v1 model pickers: claude (static presets) and codex (machine + // catalog). Other flavors' discovery endpoints are TODO(M3d+), + // so their model is never sent. + (agent == "claude" || agent == "codex") && form.model != "auto" -> form.model + else -> null + } + return SpawnSessionRequest( + directory = form.trimmedDirectory, + agent = agent, + model = resolvedModel, + effort = if (agent == "claude" && form.effort != "auto") form.effort else null, + modelReasoningEffort = if (agent == "codex" && form.modelReasoningEffort != "default") { + form.modelReasoningEffort + } else { + null + }, + yolo = if (isGrok || codexFamily) null else form.yolo, + permissionMode = if (isGrok || codexFamily) form.permissionMode else null, + sessionType = form.sessionType, + worktreeName = if (form.sessionType == SESSION_TYPE_WORKTREE) { + form.worktreeName.trim().ifEmpty { null } + } else { + null + }, + serviceTier = if (agent == "codex" && codexFastTierVisible) form.serviceTier else null, + collaborationMode = if (agent == "codex" && form.collaborationMode != "default") { + form.collaborationMode + } else { + null + }, + copilotAgentMode = if (agent == "copilot") form.copilotAgentMode else null, + startingMode = null, + ) + } + + // ------------------------------------------------------- autocomplete -- + + /** Parent listing target for the autocomplete dropdown. */ + data class ParentQuery( + /** Absolute directory to `POST list-directory`. */ + val parent: String, + /** Typed tail the entries are prefix-filtered by (case-insensitive). */ + val prefix: String, + ) + + /** + * Derives the list-directory request from the typed text: list the parent + * of the path segment being typed. Only absolute paths autocomplete + * (the hub lists runner-local absolute paths). + * + * `/data/gi` → list `/data`, prefix `gi`; `/data/` → list `/data`, no + * prefix; `/` → list `/`; relative text → null (no request). + */ + fun parentQuery(input: String): ParentQuery? { + val text = input.trim() + if (!text.startsWith("/")) return null + val lastSlash = text.lastIndexOf('/') + val parent = if (lastSlash == 0) "/" else text.substring(0, lastSlash) + return ParentQuery(parent = parent, prefix = text.substring(lastSlash + 1)) + } + + /** + * Joins a listed entry back into a full suggestion path, then filters + * to directories matching the typed prefix, capped like the web (8). + */ + fun buildSuggestions( + query: ParentQuery, + entries: List, + limit: Int = 8, + ): List { + val base = if (query.parent == "/") "/" else "${query.parent}/" + return entries.asSequence() + .filter { it.type == "directory" } + .filter { it.name.startsWith(query.prefix, ignoreCase = true) } + .map { "$base${it.name}" } + .take(limit) + .toList() + } + + // ------------------------------------------------------- recent paths -- + + /** LRU cap per machine (web caps at 5; native chips fit a couple more). */ + const val MAX_RECENT_PATHS = 8 + + /** Dedupe-to-front LRU push (web `addRecentPath`). Blank input is a no-op. */ + fun pushRecent(existing: List, path: String, cap: Int = MAX_RECENT_PATHS): List { + val trimmed = path.trim() + if (trimmed.isEmpty()) return existing + return (listOf(trimmed) + existing.filter { it != trimmed }).take(cap) + } + + // ---------------------------------------------------------- worktree -- + + /** + * Client-side worktree-name check. The runner slugs the name to + * `[a-z0-9-]` (`cli/src/runner/worktree.ts` `toSlug`); a name with no + * alphanumeric characters slugs to nothing and the spawn fails server + * side, so reject it up front. Empty is fine — the runner generates a + * `MMDD-xxxx` default. + */ + fun worktreeNameError(name: String): String? { + val trimmed = name.trim() + if (trimmed.isEmpty()) return null + return if (trimmed.none { it.isLetterOrDigit() }) { + "Name needs at least one letter or digit" + } else { + null + } + } + + // ------------------------------------------------------ codex catalog -- + + /** Active catalog entry for [model] (`'auto'` → the default row). */ + fun resolveCodexModel(models: List, model: String): CodexModelSummary? { + val normalized = model.trim() + if (normalized.isEmpty() || normalized == "auto") { + return models.firstOrNull { it.isDefault } ?: models.firstOrNull() + } + return models.firstOrNull { it.id == normalized } + } + + /** + * `codexModelAdvertisesFastTier`: the fast-mode control only appears when + * the active model's catalog row advertises a fast service tier. Empty + * catalog → hidden (no authoritative answer yet). + */ + fun codexModelAdvertisesFastTier(model: String, models: List): Boolean { + if (models.isEmpty()) return false + val normalized = model.trim().lowercase() + val active = if (normalized.isNotEmpty() && normalized != "auto") { + models.firstOrNull { it.id.trim().lowercase() == normalized } + } else { + models.firstOrNull { it.isDefault } + } + return active?.serviceTiers?.any { it.trim().contains("fast", ignoreCase = true) } == true + } + + /** + * Supported reasoning efforts of the active codex model, normalized + * (trim/lowercase/dedupe — `getCodexModelReasoningEfforts`); null when the + * catalog does not advertise any (fall back to the static list). + */ + fun codexReasoningEfforts(models: List, model: String): List? { + val efforts = resolveCodexModel(models, model)?.supportedReasoningEfforts ?: return null + val normalized = efforts.map { it.trim().lowercase() }.filter { it.isNotEmpty() }.distinct() + return normalized.ifEmpty { null } + } + + // ------------------------------------------------------------- drafts -- + + /** + * Draft sanitization on restore (web `loadNewSessionFormDraft`): an + * uncreatable/unknown agent coerces to claude and drops the + * agent-dependent fields; a permission mode outside the flavor's catalog + * resets to default. + */ + fun sanitizeDraft(draft: NewSessionForm): NewSessionForm { + val creatable = AgentFlavor.CREATABLE.any { it.id == draft.agent } + val base = if (creatable) draft else NewSessionForm( + machineId = draft.machineId, + directory = draft.directory, + agent = "claude", + yolo = draft.yolo, + sessionType = draft.sessionType, + worktreeName = draft.worktreeName, + ) + val allowedModes = PermissionModes.forFlavor(base.agent).map { it.wireId } + val sessionType = if (base.sessionType == SESSION_TYPE_WORKTREE) SESSION_TYPE_WORKTREE else SESSION_TYPE_SIMPLE + return base.copy( + permissionMode = if (base.permissionMode in allowedModes) base.permissionMode else "default", + serviceTier = if (base.serviceTier == "fast") "fast" else "standard", + collaborationMode = if (base.collaborationMode == "plan") "plan" else "default", + copilotAgentMode = app.hapi.protocol.catalog.CopilotAgentMode.normalize(base.copilotAgentMode).wireId, + sessionType = sessionType, + ) + } +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionGateway.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionGateway.kt new file mode 100644 index 0000000000..013b040a65 --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionGateway.kt @@ -0,0 +1,40 @@ +package app.hapi.companion.feature.newsession + +import app.hapi.data.api.HapiApi +import app.hapi.protocol.wire.CodexModelsResponse +import app.hapi.protocol.wire.MachineListDirectoryResponse +import app.hapi.protocol.wire.SpawnResponse +import app.hapi.protocol.wire.SpawnSessionRequest + +/** + * The four machine endpoints the create form talks to, as a seam so JVM tests + * drive [NewSessionViewModel] with fakes (the concrete [HapiApi] is final). + */ +interface NewSessionGateway { + /** `POST /api/machines/:id/spawn` — check `type`, not HTTP status. */ + suspend fun spawn(machineId: String, request: SpawnSessionRequest): SpawnResponse + + /** `POST /api/machines/:id/list-directory` (RPC-wrapped). */ + suspend fun listDirectory(machineId: String, path: String): MachineListDirectoryResponse + + /** `POST /api/machines/:id/paths/exists`. */ + suspend fun pathsExist(machineId: String, paths: List): Map + + /** `GET /api/machines/:id/codex-models` (RPC-wrapped; 503 `rpc_target_missing` = hide picker). */ + suspend fun codexModels(machineId: String): CodexModelsResponse +} + +/** Production adapter over the hub's [HapiApi]. */ +class ApiNewSessionGateway(private val api: HapiApi) : NewSessionGateway { + override suspend fun spawn(machineId: String, request: SpawnSessionRequest): SpawnResponse = + api.spawnSession(machineId, request) + + override suspend fun listDirectory(machineId: String, path: String): MachineListDirectoryResponse = + api.listMachineDirectory(machineId, path) + + override suspend fun pathsExist(machineId: String, paths: List): Map = + api.checkMachinePathsExist(machineId, paths).exists + + override suspend fun codexModels(machineId: String): CodexModelsResponse = + api.getMachineCodexModels(machineId) +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionPrefs.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionPrefs.kt new file mode 100644 index 0000000000..ad17c07fab --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionPrefs.kt @@ -0,0 +1,82 @@ +package app.hapi.companion.feature.newsession + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.stringPreferencesKey +import app.hapi.protocol.wire.HapiJson +import java.io.IOException +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first +import kotlinx.serialization.Serializable + +/** + * Create-form persistence: last-used machine + per-machine recent paths + * (web `useRecentPaths`, localStorage → DataStore here) and the in-progress + * form draft (web `newSessionFormDraft.ts`, sessionStorage → DataStore) so + * backing out of the screen loses nothing. + */ +@Serializable +data class NewSessionPrefsData( + val lastMachineId: String? = null, + /** Machine id → most-recent-first spawn directories (cap [NewSessionLogic.MAX_RECENT_PATHS]). */ + val recentPaths: Map> = emptyMap(), +) + +interface NewSessionPrefs { + suspend fun readPrefs(): NewSessionPrefsData + suspend fun writePrefs(data: NewSessionPrefsData) + + /** Null when no draft is stored (or it fails to decode). */ + suspend fun readDraft(): NewSessionForm? + suspend fun writeDraft(draft: NewSessionForm) + suspend fun clearDraft() +} + +/** + * DataStore-backed production impl. Both blobs are JSON under single string + * keys in the app-wide `hapi_prefs` store; corrupt data degrades to defaults + * (the roster storage sets the same precedent). + */ +class DataStoreNewSessionPrefs( + private val dataStore: DataStore, +) : NewSessionPrefs { + + override suspend fun readPrefs(): NewSessionPrefsData = + decode(read(PREFS_KEY), NewSessionPrefsData.serializer()) ?: NewSessionPrefsData() + + override suspend fun writePrefs(data: NewSessionPrefsData) { + dataStore.edit { it[PREFS_KEY] = HapiJson.encodeToString(NewSessionPrefsData.serializer(), data) } + } + + override suspend fun readDraft(): NewSessionForm? = + decode(read(DRAFT_KEY), NewSessionForm.serializer()) + + override suspend fun writeDraft(draft: NewSessionForm) { + dataStore.edit { it[DRAFT_KEY] = HapiJson.encodeToString(NewSessionForm.serializer(), draft) } + } + + override suspend fun clearDraft() { + dataStore.edit { it.remove(DRAFT_KEY) } + } + + private suspend fun read(key: Preferences.Key): String? = + dataStore.data + .catch { error -> if (error is IOException) emit(emptyPreferences()) else throw error } + .first()[key] + + private fun decode(raw: String?, serializer: kotlinx.serialization.KSerializer): T? { + if (raw.isNullOrEmpty()) return null + return try { + HapiJson.decodeFromString(serializer, raw) + } catch (_: Exception) { + null + } + } + + companion object { + val PREFS_KEY: Preferences.Key = stringPreferencesKey("new_session_prefs") + val DRAFT_KEY: Preferences.Key = stringPreferencesKey("new_session_draft") + } +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionScreen.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionScreen.kt new file mode 100644 index 0000000000..0fd4a381ca --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionScreen.kt @@ -0,0 +1,710 @@ +package app.hapi.companion.feature.newsession + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.AssistChip +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuAnchorType +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import app.hapi.companion.R +import app.hapi.companion.ui.components.AgentFlavorIcon +import app.hapi.companion.ui.theme.HapiTheme + +/** + * NEW SESSION (B-M3d): machine → directory → agent/options → spawn. State + * and behavior live in [NewSessionViewModel]; this screen renders + * [NewSessionUiState] and forwards intents. Successful spawns surface + * through [NewSessionViewModel.spawned] → [onCreated] (navigate-replace to + * the chat). + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NewSessionScreen( + viewModel: NewSessionViewModel, + onBack: () -> Unit, + onCreated: (sessionId: String) -> Unit, +) { + val state by viewModel.uiState.collectAsState() + + LaunchedEffect(viewModel) { + viewModel.spawned.collect(onCreated) + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.new_session_title)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.new_session_back), + ) + } + }, + ) + }, + ) { padding -> + NewSessionContent( + state = state, + modifier = Modifier + .fillMaxSize() + .padding(padding), + onMachineSelected = viewModel::setMachine, + onDirectoryChange = viewModel::setDirectory, + onSuggestionPicked = viewModel::pickSuggestion, + onRecentPathPicked = viewModel::pickRecentPath, + onSessionTypeChange = viewModel::setSessionType, + onWorktreeNameChange = viewModel::setWorktreeName, + onAgentSelected = viewModel::setAgent, + onModelSelected = viewModel::setModel, + onEffortSelected = viewModel::setEffort, + onReasoningEffortSelected = viewModel::setModelReasoningEffort, + onPermissionModeSelected = viewModel::setPermissionMode, + onYoloToggle = viewModel::setYolo, + onCollaborationModeSelected = viewModel::setCollaborationMode, + onCopilotAgentModeSelected = viewModel::setCopilotAgentMode, + onServiceTierSelected = viewModel::setServiceTier, + onCreate = viewModel::create, + ) + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +internal fun NewSessionContent( + state: NewSessionUiState, + modifier: Modifier = Modifier, + onMachineSelected: (String) -> Unit, + onDirectoryChange: (String) -> Unit, + onSuggestionPicked: (String) -> Unit, + onRecentPathPicked: (String) -> Unit, + onSessionTypeChange: (String) -> Unit, + onWorktreeNameChange: (String) -> Unit, + onAgentSelected: (String) -> Unit, + onModelSelected: (String) -> Unit, + onEffortSelected: (String) -> Unit, + onReasoningEffortSelected: (String) -> Unit, + onPermissionModeSelected: (String) -> Unit, + onYoloToggle: (Boolean) -> Unit, + onCollaborationModeSelected: (String) -> Unit, + onCopilotAgentModeSelected: (String) -> Unit, + onServiceTierSelected: (String) -> Unit, + onCreate: () -> Unit, +) { + val form = state.form + Column( + modifier = modifier + .verticalScroll(rememberScrollState()) + .imePadding() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + MachineSection(state, onMachineSelected) + DirectorySection( + state = state, + onDirectoryChange = onDirectoryChange, + onSuggestionPicked = onSuggestionPicked, + onRecentPathPicked = onRecentPathPicked, + ) + SessionTypeSection(state, onSessionTypeChange, onWorktreeNameChange) + AgentSection(state, onAgentSelected) + + state.modelOptions?.let { options -> + OptionDropdown( + label = stringResource(R.string.new_session_model), + options = options, + selected = form.model, + enabled = !state.isSpawning && !state.modelsLoading && state.modelsError == null, + loading = state.modelsLoading, + supportingError = state.modelsError, + onSelect = onModelSelected, + ) + } + state.effortOptions?.let { options -> + OptionDropdown( + label = stringResource(R.string.new_session_effort), + options = options, + selected = form.effort, + enabled = !state.isSpawning, + onSelect = onEffortSelected, + ) + } + state.reasoningEffortOptions?.let { options -> + OptionDropdown( + label = stringResource(R.string.new_session_reasoning_effort), + options = options, + selected = form.modelReasoningEffort, + enabled = !state.isSpawning && !state.modelsLoading, + onSelect = onReasoningEffortSelected, + ) + } + + PermissionSection(state, onPermissionModeSelected, onYoloToggle) + + if (state.showCollaborationMode) { + OptionDropdown( + label = stringResource(R.string.new_session_collaboration_mode), + options = state.collaborationModeOptions, + selected = form.collaborationMode, + enabled = !state.isSpawning, + onSelect = onCollaborationModeSelected, + ) + } + if (state.showCopilotAgentMode) { + OptionDropdown( + label = stringResource(R.string.new_session_copilot_agent_mode), + options = state.copilotAgentModeOptions, + selected = form.copilotAgentMode, + enabled = !state.isSpawning, + onSelect = onCopilotAgentModeSelected, + ) + } + if (state.showFastMode) { + OptionDropdown( + label = stringResource(R.string.new_session_fast_mode), + options = listOf( + OptionItem("standard", stringResource(R.string.new_session_fast_mode_standard)), + OptionItem("fast", stringResource(R.string.new_session_fast_mode_fast)), + ), + selected = form.serviceTier, + enabled = !state.isSpawning, + onSelect = onServiceTierSelected, + ) + } + + state.spawnError?.let { error -> + Text( + text = error, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + + Button( + onClick = onCreate, + enabled = state.canCreate, + modifier = Modifier.fillMaxWidth(), + ) { + if (state.isSpawning) { + CircularProgressIndicator( + modifier = Modifier.padding(end = 8.dp), + strokeWidth = 2.dp, + ) + } + Text( + text = stringResource( + when { + state.isSpawning -> R.string.new_session_creating + state.confirmCreateDirectory -> R.string.new_session_create_and_make_directory + else -> R.string.new_session_create + }, + ), + ) + } + } +} + +// ---------------------------------------------------------------- machine -- + +@Composable +private fun MachineSection(state: NewSessionUiState, onMachineSelected: (String) -> Unit) { + val selected = state.machines.firstOrNull { it.id == state.form.machineId } + OptionDropdown( + label = stringResource(R.string.new_session_machine), + options = state.machines.map { OptionItem(it.id, it.label) }, + selected = state.form.machineId.orEmpty(), + selectedLabelFallback = when { + state.machinesLoading -> stringResource(R.string.new_session_machines_loading) + state.machines.isEmpty() -> stringResource(R.string.new_session_no_machines) + else -> "" + }, + enabled = !state.isSpawning && state.machines.isNotEmpty(), + onSelect = onMachineSelected, + ) + selected?.healthLabel?.let { health -> + Text( + text = health, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + state.runnerSpawnError?.let { error -> + Text( + text = stringResource(R.string.new_session_runner_spawn_error, error), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } +} + +// -------------------------------------------------------------- directory -- + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun DirectorySection( + state: NewSessionUiState, + onDirectoryChange: (String) -> Unit, + onSuggestionPicked: (String) -> Unit, + onRecentPathPicked: (String) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + OutlinedTextField( + value = state.form.directory, + onValueChange = onDirectoryChange, + label = { Text(stringResource(R.string.new_session_directory)) }, + placeholder = { Text(stringResource(R.string.new_session_directory_placeholder)) }, + singleLine = true, + enabled = !state.isSpawning, + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false, + imeAction = ImeAction.Done, + ), + modifier = Modifier.fillMaxWidth(), + ) + + // Server-side autocomplete (list-directory on the parent path). + if (state.suggestions.isNotEmpty()) { + Surface( + tonalElevation = 2.dp, + shape = MaterialTheme.shapes.small, + modifier = Modifier.fillMaxWidth(), + ) { + Column { + state.suggestions.forEachIndexed { index, suggestion -> + if (index > 0) HorizontalDivider() + DropdownMenuItem( + text = { + Text( + text = suggestion, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + onClick = { onSuggestionPicked(suggestion) }, + ) + } + } + } + } + + if (state.recentPaths.isNotEmpty()) { + Text( + text = stringResource(R.string.new_session_recent_paths), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(0.dp), + ) { + state.recentPaths.forEach { path -> + AssistChip( + onClick = { onRecentPathPicked(path) }, + enabled = !state.isSpawning, + label = { + Text( + // Chips get crowded fast; lead with the tail. + text = path, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + ) + } + } + } + + state.directoryStatus?.let { status -> + Text( + text = status.message, + style = MaterialTheme.typography.bodySmall, + color = if (status.isError) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } +} + +// ----------------------------------------------------------- session type -- + +@Composable +private fun SessionTypeSection( + state: NewSessionUiState, + onSessionTypeChange: (String) -> Unit, + onWorktreeNameChange: (String) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + SectionLabel(stringResource(R.string.new_session_type)) + Row(verticalAlignment = Alignment.CenterVertically) { + RadioButton( + selected = state.form.sessionType == SESSION_TYPE_SIMPLE, + onClick = { onSessionTypeChange(SESSION_TYPE_SIMPLE) }, + enabled = !state.isSpawning, + ) + Column { + Text(stringResource(R.string.new_session_type_simple), style = MaterialTheme.typography.bodyMedium) + Text( + text = stringResource(R.string.new_session_type_simple_desc), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Row(verticalAlignment = Alignment.CenterVertically) { + RadioButton( + selected = state.form.sessionType == SESSION_TYPE_WORKTREE, + onClick = { onSessionTypeChange(SESSION_TYPE_WORKTREE) }, + enabled = !state.isSpawning, + ) + Column { + Text(stringResource(R.string.new_session_type_worktree), style = MaterialTheme.typography.bodyMedium) + Text( + text = stringResource(R.string.new_session_type_worktree_desc), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + if (state.form.sessionType == SESSION_TYPE_WORKTREE) { + OutlinedTextField( + value = state.form.worktreeName, + onValueChange = onWorktreeNameChange, + label = { Text(stringResource(R.string.new_session_worktree_name)) }, + placeholder = { Text(stringResource(R.string.new_session_worktree_name_placeholder)) }, + singleLine = true, + enabled = !state.isSpawning, + isError = state.worktreeNameError != null, + supportingText = state.worktreeNameError?.let { { Text(it) } }, + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false, + ), + modifier = Modifier + .fillMaxWidth() + .padding(start = 12.dp), + ) + } + } +} + +// ------------------------------------------------------------------ agent -- + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun AgentSection(state: NewSessionUiState, onAgentSelected: (String) -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + SectionLabel(stringResource(R.string.new_session_agent)) + FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + state.agents.forEach { agent -> + FilterChip( + selected = state.form.agent == agent.value, + onClick = { onAgentSelected(agent.value) }, + enabled = !state.isSpawning, + leadingIcon = { AgentFlavorIcon(agent.value, modifier = Modifier.size(16.dp)) }, + label = { Text(agent.label) }, + ) + } + } + } +} + +// ------------------------------------------------------------- permission -- + +@Composable +private fun PermissionSection( + state: NewSessionUiState, + onPermissionModeSelected: (String) -> Unit, + onYoloToggle: (Boolean) -> Unit, +) { + when (val permission = state.permission) { + is PermissionUi.NativeSelect -> OptionDropdown( + label = stringResource(R.string.new_session_permission_mode), + options = permission.options, + selected = state.form.permissionMode, + enabled = !state.isSpawning, + onSelect = onPermissionModeSelected, + ) + + is PermissionUi.YoloToggle -> Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + SectionLabel(stringResource(R.string.new_session_yolo)) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.new_session_yolo_title), + style = MaterialTheme.typography.bodyMedium, + ) + Text( + text = stringResource(R.string.new_session_yolo_desc), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + permission.nativeModeLabel?.let { mode -> + Text( + text = stringResource(R.string.new_session_yolo_maps_to, mode), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Switch( + checked = state.form.yolo, + onCheckedChange = onYoloToggle, + enabled = !state.isSpawning, + ) + } + } + + PermissionUi.Managed -> Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + SectionLabel(stringResource(R.string.new_session_permission_mode)) + Text( + text = stringResource(R.string.new_session_permission_managed), + style = MaterialTheme.typography.bodyMedium, + ) + Text( + text = stringResource(R.string.new_session_permission_managed_desc), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +// ---------------------------------------------------------------- helpers -- + +@Composable +private fun SectionLabel(text: String) { + Text( + text = text, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun OptionDropdown( + label: String, + options: List, + selected: String, + enabled: Boolean, + onSelect: (String) -> Unit, + loading: Boolean = false, + supportingError: String? = null, + selectedLabelFallback: String = "", +) { + var expanded by remember { mutableStateOf(false) } + val selectedLabel = options.firstOrNull { it.value == selected }?.label ?: selectedLabelFallback + + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + ExposedDropdownMenuBox( + expanded = expanded && enabled, + onExpandedChange = { if (enabled) expanded = it }, + ) { + OutlinedTextField( + value = if (loading) stringResource(R.string.new_session_loading) else selectedLabel, + onValueChange = {}, + readOnly = true, + enabled = enabled, + label = { Text(label) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded && enabled) }, + modifier = Modifier + .fillMaxWidth() + .menuAnchor(MenuAnchorType.PrimaryNotEditable, enabled), + ) + ExposedDropdownMenu( + expanded = expanded && enabled, + onDismissRequest = { expanded = false }, + ) { + options.forEach { option -> + DropdownMenuItem( + text = { Text(option.label) }, + onClick = { + expanded = false + onSelect(option.value) + }, + ) + } + } + } + supportingError?.let { error -> + Text( + text = error, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } +} + +// ---------------------------------------------------------------- preview -- + +private fun previewState(form: NewSessionForm): NewSessionUiState = NewSessionUiState( + form = form, + machines = listOf( + MachineOptionUi("m1", "devbox (linux) · CLI 0.42.0", "CPU 12% · Mem 45%"), + MachineOptionUi("m2", "mac-mini (darwin) · CLI 0.42.0", null), + ), + machinesLoading = false, + runnerSpawnError = null, + suggestions = listOf("/data/github/hapi", "/data/github/hub"), + recentPaths = listOf("/data/github/hapi", "/home/dev/scratch"), + directoryStatus = DirectoryStatusUi( + NewSessionViewModel.MSG_DIRECTORY_MISSING, + isError = false, + ), + agents = listOf( + OptionItem("claude", "Claude"), + OptionItem("codex", "Codex"), + OptionItem("grok", "Grok Build"), + ), + modelOptions = NewSessionCatalogs.CLAUDE_MODELS, + modelsLoading = false, + modelsError = null, + effortOptions = NewSessionCatalogs.CLAUDE_EFFORTS, + reasoningEffortOptions = null, + permission = PermissionUi.YoloToggle("Yolo"), + showCollaborationMode = false, + collaborationModeOptions = emptyList(), + showFastMode = false, + showCopilotAgentMode = false, + copilotAgentModeOptions = emptyList(), + worktreeNameError = null, + isSpawning = false, + spawnError = null, + canCreate = true, + confirmCreateDirectory = false, +) + +@Preview(showBackground = true) +@Composable +private fun NewSessionPreviewClaude() { + HapiTheme { + Surface { + NewSessionContent( + state = previewState( + NewSessionForm(machineId = "m1", directory = "/data/github/hap", agent = "claude"), + ), + onMachineSelected = {}, + onDirectoryChange = {}, + onSuggestionPicked = {}, + onRecentPathPicked = {}, + onSessionTypeChange = {}, + onWorktreeNameChange = {}, + onAgentSelected = {}, + onModelSelected = {}, + onEffortSelected = {}, + onReasoningEffortSelected = {}, + onPermissionModeSelected = {}, + onYoloToggle = {}, + onCollaborationModeSelected = {}, + onCopilotAgentModeSelected = {}, + onServiceTierSelected = {}, + onCreate = {}, + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun NewSessionPreviewCodexWorktree() { + HapiTheme { + Surface { + NewSessionContent( + state = previewState( + NewSessionForm( + machineId = "m1", + directory = "/data/github/hapi", + agent = "codex", + sessionType = SESSION_TYPE_WORKTREE, + worktreeName = "feature-x", + ), + ).copy( + modelOptions = listOf(OptionItem("auto", "Default"), OptionItem("gpt-5.2-codex", "GPT-5.2 Codex")), + effortOptions = null, + reasoningEffortOptions = NewSessionCatalogs.CODEX_REASONING_EFFORTS, + permission = PermissionUi.NativeSelect( + listOf( + OptionItem("default", "Default"), + OptionItem("read-only", "Read Only"), + OptionItem("safe-yolo", "Safe Yolo"), + OptionItem("yolo", "Yolo"), + ), + ), + showCollaborationMode = true, + collaborationModeOptions = listOf(OptionItem("default", "Default"), OptionItem("plan", "Plan")), + showFastMode = true, + directoryStatus = null, + ), + onMachineSelected = {}, + onDirectoryChange = {}, + onSuggestionPicked = {}, + onRecentPathPicked = {}, + onSessionTypeChange = {}, + onWorktreeNameChange = {}, + onAgentSelected = {}, + onModelSelected = {}, + onEffortSelected = {}, + onReasoningEffortSelected = {}, + onPermissionModeSelected = {}, + onYoloToggle = {}, + onCollaborationModeSelected = {}, + onCopilotAgentModeSelected = {}, + onServiceTierSelected = {}, + onCreate = {}, + ) + } + } +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionViewModel.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionViewModel.kt new file mode 100644 index 0000000000..ca43c7d9ff --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionViewModel.kt @@ -0,0 +1,701 @@ +package app.hapi.companion.feature.newsession + +import app.hapi.companion.feature.newsession.NewSessionLogic.buildSpawnRequest +import app.hapi.companion.feature.newsession.NewSessionLogic.parentQuery +import app.hapi.companion.feature.newsession.NewSessionLogic.pushRecent +import app.hapi.companion.feature.newsession.NewSessionLogic.usesNativePermissionSelect +import app.hapi.companion.feature.newsession.NewSessionLogic.worktreeNameError +import app.hapi.data.api.ApiError +import app.hapi.data.store.MachineListStore +import app.hapi.protocol.catalog.AgentFlavor +import app.hapi.protocol.catalog.CodexCollaborationMode +import app.hapi.protocol.catalog.CopilotAgentMode +import app.hapi.protocol.catalog.Flavors +import app.hapi.protocol.catalog.PermissionMode +import app.hapi.protocol.catalog.PermissionModes +import app.hapi.protocol.wire.CodexModelSummary +import app.hapi.protocol.wire.Machine +import app.hapi.protocol.wire.MachineDirectoryEntry +import app.hapi.protocol.wire.objOrNull +import app.hapi.protocol.wire.longOrNull +import app.hapi.protocol.wire.stringOrNull +import java.text.DateFormat +import java.util.Date +import kotlin.coroutines.cancellation.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +// ------------------------------------------------------------- UI models -- + +data class MachineOptionUi( + val id: String, + /** `displayName || host || id.take(8)` plus ` (platform)` and CLI version. */ + val label: String, + /** e.g. `CPU 12% · Mem 45%`; null when the runner reports no health. */ + val healthLabel: String?, +) + +/** Directory hint under the input (web `directoryStatusMessage` + tone). */ +data class DirectoryStatusUi(val message: String, val isError: Boolean) + +/** + * User-facing strings the form ViewModel needs (B-M5a Strings seam): defaults + * are the pre-i18n English (JVM tests construct without arguments; the MSG_* + * companion constants they assert against alias these defaults); production + * passes resource-resolved values from the Navigation holder. + */ +class NewSessionStrings( + val worktreeMissing: String = NewSessionViewModel.MSG_WORKTREE_MISSING, + val directoryMissing: String = NewSessionViewModel.MSG_DIRECTORY_MISSING, + val directoryMissingConfirm: String = NewSessionViewModel.MSG_DIRECTORY_MISSING_CONFIRM, + val createFailed: String = "Failed to create session", + val codexModelsFailed: String = "Failed to load Codex models", + /** `%1$s` = failure detail. */ + val modelsFailedDetail: String = "Failed to load models: %1\$s", + val worktreeNameInvalid: String = "Name needs at least one letter or digit", +) + +/** Which permission control the current flavor renders (web `PermissionField`). */ +sealed interface PermissionUi { + /** Native permission-mode picker (grok + codex-family). */ + data class NativeSelect(val options: List) : PermissionUi + + /** HAPI YOLO toggle (claude/agy/cursor) with the native mode it maps to. */ + data class YoloToggle(val nativeModeLabel: String?) : PermissionUi + + /** Pi: the agent manages its own permissions. */ + data object Managed : PermissionUi +} + +/** Machine codex-models catalog state (web `useCodexModels`). */ +sealed interface CodexModelsUi { + data object Hidden : CodexModelsUi + data object Loading : CodexModelsUi + data class Loaded(val models: List) : CodexModelsUi + + /** Runner has no machine RPC (`rpc_target_missing`) — hide the picker. */ + data object Unsupported : CodexModelsUi + data class Failed(val message: String) : CodexModelsUi +} + +data class NewSessionUiState( + val form: NewSessionForm, + val machines: List, + val machinesLoading: Boolean, + /** `runnerState.lastSpawnError` of the selected machine, formatted. */ + val runnerSpawnError: String?, + val suggestions: List, + val recentPaths: List, + val directoryStatus: DirectoryStatusUi?, + /** Creatable flavors (value = flavor id, label from the catalog). */ + val agents: List, + /** Null hides the model picker (v1: only claude + supported codex). */ + val modelOptions: List?, + val modelsLoading: Boolean, + val modelsError: String?, + /** Claude launch-effort options; null hides the field. */ + val effortOptions: List?, + /** Codex reasoning-effort options; null hides the field. */ + val reasoningEffortOptions: List?, + val permission: PermissionUi, + val showCollaborationMode: Boolean, + val collaborationModeOptions: List, + val showFastMode: Boolean, + val showCopilotAgentMode: Boolean, + val copilotAgentModeOptions: List, + val worktreeNameError: String?, + val isSpawning: Boolean, + val spawnError: String?, + val canCreate: Boolean, + /** Armed after the first Create tap on a missing simple directory. */ + val confirmCreateDirectory: Boolean, +) + +/** + * New-session state machine (B-M3d): machine → directory → agent/options → + * spawn. Plain constructor over fake-able seams ([NewSessionGateway], + * [MachineListStore], [NewSessionPrefs]) so JVM tests drive the whole flow. + * Web reference: `web/src/components/NewSession/index.tsx`. + * + * v1 notes (deliberate cuts, mirrored in the option matrix): + * - model pickers: claude (static presets) + codex (machine catalog; hidden + * when the runner lacks the RPC). agy/opencode/grok/copilot/cursor/pi model + * discovery is TODO — spawn omits `model` so the agent default applies. + * - effort: claude static levels; codex reasoning effort from the catalog. + * grok/pi effort ships with their model discovery. + * - grok's `auto` availability probe (directory-scoped grok-models) is + * deferred; all catalog modes are offered. + * - `startingMode` stays unset → the runner spawns `'remote'` (pty deferred). + */ +class NewSessionViewModel( + private val gateway: NewSessionGateway, + private val machineStore: MachineListStore, + private val prefs: NewSessionPrefs, + private val scope: CoroutineScope, + initialMachineId: String? = null, + private val debounceMs: Long = 250L, + private val strings: NewSessionStrings = NewSessionStrings(), +) { + private val form = MutableStateFlow(NewSessionForm()) + private val prefsData = MutableStateFlow(NewSessionPrefsData()) + private val codexModels = MutableStateFlow(CodexModelsUi.Hidden) + private val suggestions = MutableStateFlow>(emptyList()) + private val pathExistence = MutableStateFlow>(emptyMap()) + private val isSpawning = MutableStateFlow(false) + private val spawnError = MutableStateFlow(null) + private val confirmCreateDirectory = MutableStateFlow(false) + private val machinesRefreshSettled = MutableStateFlow(false) + + private val _spawned = MutableSharedFlow(extraBufferCapacity = 1) + + /** Emits the new session id once — navigate-replace to `chat/{id}`. */ + val spawned: SharedFlow = _spawned.asSharedFlow() + + private var directoryJob: Job? = null + private var codexJob: Job? = null + private var codexFetchedForMachine: String? = null + private var suppressSuggestions = false + private var spawnInFlight = false + + /** Parent-listing cache: retyping within the same parent re-filters locally. */ + private var cachedListing: Pair, List>? = null + + init { + scope.launch { + prefsData.value = runCatching { prefs.readPrefs() }.getOrDefault(NewSessionPrefsData()) + val draft = runCatching { prefs.readDraft() }.getOrNull()?.let(NewSessionLogic::sanitizeDraft) + var initial = draft ?: NewSessionForm() + if (initialMachineId != null) { + if (draft?.machineId != null && draft.machineId != initialMachineId) { + // Draft belongs to another machine (web `newSessionDraftMatchesMachine`). + initial = NewSessionForm(machineId = initialMachineId) + runCatching { prefs.clearDraft() } + } else { + initial = initial.copy(machineId = initialMachineId) + } + } + if (initial.machineId != null && initial.directory.isBlank()) { + initial = initial.copy(directory = recentPathsFor(initial.machineId).firstOrNull().orEmpty()) + } + form.value = initial + refreshCodexModelsIfNeeded() + // A restored directory should probe existence but not pop the + // autocomplete dropdown — suggestions belong to typing. + suppressSuggestions = true + scheduleDirectoryWork() + + // Draft persistence: every later edit lands in DataStore so + // backing out never loses input (web `newSessionFormDraft.ts`). + launch { + form.drop(1).collectLatest { current -> + runCatching { prefs.writeDraft(current) } + } + } + + // Machine preselect once the (snapshot or fetched) roster is in: + // keep a still-online selection; otherwise last-used, else first. + launch { + machineStore.machines.collect { machines -> + if (machines.isEmpty()) return@collect + val current = form.value.machineId + if (current != null && machines.any { it.id == current }) return@collect + val lastUsed = prefsData.value.lastMachineId + val target = machines.firstOrNull { it.id == lastUsed } ?: machines.first() + applyMachineSelection(target.id, resetDirectory = form.value.directory.isBlank()) + } + } + } + scope.launch { + try { + machineStore.refresh() + } catch (cancellation: CancellationException) { + throw cancellation + } catch (_: Exception) { + // Snapshot (if any) keeps serving; the picker shows what it has. + } finally { + machinesRefreshSettled.value = true + } + } + } + + // -------------------------------------------------------------- state -- + + val uiState: StateFlow = combine( + form, + machineStore.machines, + codexModels, + combine(suggestions, pathExistence, prefsData) { s, exists, stored -> Triple(s, exists, stored) }, + combine(isSpawning, spawnError, confirmCreateDirectory, machinesRefreshSettled) { + spawning, error, confirmed, settled -> + SpawnFlags(spawning, error, confirmed, settled) + }, + ) { currentForm, machines, codex, (currentSuggestions, exists, stored), flags -> + buildUiState(currentForm, machines, codex, currentSuggestions, exists, stored, flags) + }.stateIn( + scope = scope, + started = SharingStarted.Eagerly, + initialValue = buildUiState( + form.value, + machineStore.machines.value, + codexModels.value, + emptyList(), + emptyMap(), + prefsData.value, + SpawnFlags(isSpawning = false, spawnError = null, confirmed = false, machinesSettled = false), + ), + ) + + private data class SpawnFlags( + val isSpawning: Boolean, + val spawnError: String?, + val confirmed: Boolean, + val machinesSettled: Boolean, + ) + + // ------------------------------------------------------------ actions -- + + fun setMachine(machineId: String) { + if (machineId == form.value.machineId) return + applyMachineSelection(machineId, resetDirectory = true) + } + + fun setDirectory(value: String) { + suppressSuggestions = false + confirmCreateDirectory.value = false + form.update { it.copy(directory = value) } + scheduleDirectoryWork() + } + + fun pickSuggestion(path: String) = pickPath(path) + + fun pickRecentPath(path: String) = pickPath(path) + + fun setAgent(agent: String) { + if (agent == form.value.agent) return + // Web parity: switching agents resets every agent-dependent field + // (yolo is a cross-flavor preference and survives). + form.update { + it.copy( + agent = agent, + model = "auto", + effort = "auto", + modelReasoningEffort = "default", + permissionMode = "default", + serviceTier = "standard", + collaborationMode = "default", + copilotAgentMode = "interactive", + ) + } + refreshCodexModelsIfNeeded() + } + + fun setModel(model: String) { + form.update { current -> + val next = current.copy(model = model) + if (current.agent == "codex") reconcileCodexSelections(next) else next + } + } + + fun setEffort(effort: String) = form.update { it.copy(effort = effort) } + + fun setModelReasoningEffort(value: String) = form.update { it.copy(modelReasoningEffort = value) } + + fun setPermissionMode(mode: String) = form.update { it.copy(permissionMode = mode) } + + fun setYolo(enabled: Boolean) = form.update { it.copy(yolo = enabled) } + + fun setSessionType(sessionType: String) { + confirmCreateDirectory.value = false + form.update { it.copy(sessionType = sessionType) } + } + + fun setWorktreeName(name: String) = form.update { it.copy(worktreeName = name) } + + fun setServiceTier(tier: String) = form.update { it.copy(serviceTier = tier) } + + fun setCollaborationMode(mode: String) = form.update { it.copy(collaborationMode = mode) } + + fun setCopilotAgentMode(mode: String) = form.update { it.copy(copilotAgentMode = mode) } + + fun retryCodexModels() { + codexFetchedForMachine = null + refreshCodexModelsIfNeeded() + } + + /** + * Spawn. Directory existence is re-checked server-side first (web + * `handleCreate`): a missing worktree base is an error; a missing simple + * directory arms a second-tap confirmation, after which the hub creates + * it. Success emits [spawned]; failure lands in the inline error. + */ + fun create() { + val current = form.value + val machineId = current.machineId ?: return + if (current.trimmedDirectory.isEmpty() || spawnInFlight) return + if (worktreeNameBlocks(current)) return + spawnInFlight = true + isSpawning.value = true + spawnError.value = null + scope.launch { + try { + val directory = current.trimmedDirectory + val exists = runCatching { gateway.pathsExist(machineId, listOf(directory)) } + .getOrDefault(emptyMap())[directory] + if (exists != null) { + pathExistence.update { it + (directory to exists) } + } + if (current.sessionType == SESSION_TYPE_WORKTREE && exists == false) { + spawnError.value = strings.worktreeMissing + return@launch + } + if (current.sessionType == SESSION_TYPE_SIMPLE && exists == false && !confirmCreateDirectory.value) { + confirmCreateDirectory.value = true + return@launch + } + + val request = buildSpawnRequest(current, codexFastTierVisible(current)) + val result = gateway.spawn(machineId, request) + if (result.type == "success" && result.sessionId != null) { + persistOnSuccess(machineId, directory) + _spawned.tryEmit(result.sessionId!!) + } else { + spawnError.value = result.message ?: strings.createFailed + } + } catch (cancellation: CancellationException) { + throw cancellation + } catch (error: Exception) { + spawnError.value = error.message ?: strings.createFailed + } finally { + spawnInFlight = false + isSpawning.value = false + } + } + } + + // ---------------------------------------------------------- internals -- + + private fun pickPath(path: String) { + suppressSuggestions = true + confirmCreateDirectory.value = false + suggestions.value = emptyList() + form.update { it.copy(directory = path) } + scheduleDirectoryWork() + } + + private fun recentPathsFor(machineId: String?): List = + machineId?.let { prefsData.value.recentPaths[it] }.orEmpty() + + private fun applyMachineSelection(machineId: String, resetDirectory: Boolean) { + pathExistence.value = emptyMap() + suggestions.value = emptyList() + cachedListing = null + confirmCreateDirectory.value = false + // The seeded recent path is a pick, not typing — no dropdown. + suppressSuggestions = true + form.update { current -> + current.copy( + machineId = machineId, + model = "auto", + directory = if (resetDirectory) { + recentPathsFor(machineId).firstOrNull().orEmpty() + } else { + current.directory + }, + ) + } + refreshCodexModelsIfNeeded() + scheduleDirectoryWork() + } + + /** Debounced directory work: parent listing for autocomplete + exists probe. */ + private fun scheduleDirectoryWork() { + directoryJob?.cancel() + val machineId = form.value.machineId + if (machineId == null) { + suggestions.value = emptyList() + return + } + directoryJob = scope.launch { + delay(debounceMs) + val text = form.value.directory + val trimmed = text.trim() + + val query = if (suppressSuggestions) null else parentQuery(text) + if (query == null) { + suggestions.value = emptyList() + } else { + val cacheKey = machineId to query.parent + val cached = cachedListing?.takeIf { it.first == cacheKey }?.second + val entries = cached ?: try { + val response = gateway.listDirectory(machineId, query.parent) + val listed = if (response.success) response.entries.orEmpty() else emptyList() + if (response.success) cachedListing = cacheKey to listed + listed + } catch (cancellation: CancellationException) { + throw cancellation + } catch (_: Exception) { + emptyList() + } + // Never suggest the path already typed verbatim. + suggestions.value = NewSessionLogic.buildSuggestions(query, entries) + .filter { it != trimmed } + } + + if (trimmed.isNotEmpty()) { + try { + val result = gateway.pathsExist(machineId, listOf(trimmed)) + pathExistence.update { it + result } + } catch (cancellation: CancellationException) { + throw cancellation + } catch (_: Exception) { + // Unknown existence: no status hint, spawn re-checks anyway. + } + } + } + } + + private fun refreshCodexModelsIfNeeded() { + val current = form.value + if (current.agent != "codex") { + codexJob?.cancel() + codexFetchedForMachine = null + codexModels.value = CodexModelsUi.Hidden + return + } + val machineId = current.machineId + if (machineId == null) { + codexModels.value = CodexModelsUi.Hidden + return + } + if (codexFetchedForMachine == machineId && codexModels.value !is CodexModelsUi.Hidden) return + codexFetchedForMachine = machineId + codexJob?.cancel() + codexModels.value = CodexModelsUi.Loading + codexJob = scope.launch { + val state = try { + val response = gateway.codexModels(machineId) + if (response.success) { + CodexModelsUi.Loaded(response.models.orEmpty()) + } else { + CodexModelsUi.Failed(response.error ?: strings.codexModelsFailed) + } + } catch (cancellation: CancellationException) { + throw cancellation + } catch (error: ApiError) { + if (error.code == "rpc_target_missing") { + CodexModelsUi.Unsupported + } else { + CodexModelsUi.Failed(error.message ?: strings.codexModelsFailed) + } + } catch (error: Exception) { + CodexModelsUi.Failed(error.message ?: strings.codexModelsFailed) + } + codexModels.value = state + if (state is CodexModelsUi.Loaded) { + // Reconcile restored selections with the live catalog (web + // validation effects): unknown model → auto; unsupported + // effort → default; no fast tier → standard. + form.update { reconcileCodexSelections(it) } + } + } + } + + private fun reconcileCodexSelections(current: NewSessionForm): NewSessionForm { + val loaded = codexModels.value as? CodexModelsUi.Loaded ?: return current + var next = current + if (next.model != "auto" && loaded.models.none { it.id == next.model }) { + next = next.copy(model = "auto") + } + val supported = NewSessionLogic.codexReasoningEfforts(loaded.models, next.model) + if (next.modelReasoningEffort != "default" && supported != null && next.modelReasoningEffort !in supported) { + next = next.copy(modelReasoningEffort = "default") + } + if (!NewSessionLogic.codexModelAdvertisesFastTier(next.model, loaded.models) && next.serviceTier != "standard") { + next = next.copy(serviceTier = "standard") + } + return next + } + + private fun codexFastTierVisible(current: NewSessionForm): Boolean { + if (current.agent != "codex") return false + val loaded = codexModels.value as? CodexModelsUi.Loaded ?: return false + return NewSessionLogic.codexModelAdvertisesFastTier(current.model, loaded.models) + } + + private fun worktreeNameBlocks(current: NewSessionForm): Boolean = + current.sessionType == SESSION_TYPE_WORKTREE && worktreeNameError(current.worktreeName) != null + + private suspend fun persistOnSuccess(machineId: String, directory: String) { + val updated = prefsData.value.let { stored -> + stored.copy( + lastMachineId = machineId, + recentPaths = stored.recentPaths + (machineId to pushRecent(stored.recentPaths[machineId].orEmpty(), directory)), + ) + } + prefsData.value = updated + runCatching { prefs.writePrefs(updated) } + runCatching { prefs.clearDraft() } + } + + // ------------------------------------------------------------ mapping -- + + private fun buildUiState( + currentForm: NewSessionForm, + machines: List, + codex: CodexModelsUi, + currentSuggestions: List, + exists: Map, + stored: NewSessionPrefsData, + flags: SpawnFlags, + ): NewSessionUiState { + val agent = currentForm.agent + val selectedMachine = machines.firstOrNull { it.id == currentForm.machineId } + val trimmed = currentForm.trimmedDirectory + val directoryExists = if (trimmed.isEmpty()) null else exists[trimmed] + + val missingWorktreeDirectory = + currentForm.sessionType == SESSION_TYPE_WORKTREE && trimmed.isNotEmpty() && directoryExists == false + val needsCreationWarning = + currentForm.sessionType == SESSION_TYPE_SIMPLE && trimmed.isNotEmpty() && directoryExists == false + val directoryStatus = when { + missingWorktreeDirectory -> DirectoryStatusUi(strings.worktreeMissing, isError = true) + needsCreationWarning -> DirectoryStatusUi( + if (flags.confirmed) strings.directoryMissingConfirm else strings.directoryMissing, + isError = false, + ) + else -> null + } + + val modelOptions: List? = when { + agent == "claude" -> NewSessionCatalogs.CLAUDE_MODELS + agent == "codex" && codex is CodexModelsUi.Loaded -> { + listOf(OptionItem("auto", "Default")) + codex.models.map { OptionItem(it.id, it.displayName) } + } + agent == "codex" && (codex is CodexModelsUi.Loading || codex is CodexModelsUi.Failed) -> + listOf(OptionItem("auto", "Default")) + // codex Unsupported (old runner) and every other flavor: hidden. + else -> null + } + + val reasoningEffortOptions: List? = if (agent == "codex" && codex !is CodexModelsUi.Unsupported) { + val advertised = (codex as? CodexModelsUi.Loaded) + ?.let { NewSessionLogic.codexReasoningEfforts(it.models, currentForm.model) } + advertised?.let { efforts -> + listOf(OptionItem("default", "Default")) + + efforts.map { OptionItem(it, NewSessionCatalogs.effortLabel(it)) } + } ?: NewSessionCatalogs.CODEX_REASONING_EFFORTS + } else { + null + } + + val permission: PermissionUi = when { + agent == "pi" -> PermissionUi.Managed + usesNativePermissionSelect(agent) -> PermissionUi.NativeSelect( + PermissionModes.forFlavor(agent).map { OptionItem(it.wireId, it.label) }, + ) + else -> PermissionUi.YoloToggle(hapiYoloNativeMode(agent)?.label) + } + + val showFastMode = agent == "codex" && codex is CodexModelsUi.Loaded && + NewSessionLogic.codexModelAdvertisesFastTier(currentForm.model, codex.models) + + // Web `isLaunchPreferenceValidationPending` (codex slice): a restored + // codex model/effort must not spawn before the catalog validated it. + val codexValidationPending = agent == "codex" && codex is CodexModelsUi.Loading && + (currentForm.model != "auto" || currentForm.modelReasoningEffort != "default" || currentForm.serviceTier == "fast") + + val nameError = if (currentForm.sessionType == SESSION_TYPE_WORKTREE) { + worktreeNameError(currentForm.worktreeName)?.let { strings.worktreeNameInvalid } + } else { + null + } + + return NewSessionUiState( + form = currentForm, + machines = machines.map { machineOption(it) }, + machinesLoading = machines.isEmpty() && !flags.machinesSettled, + runnerSpawnError = formatRunnerSpawnError(selectedMachine), + suggestions = currentSuggestions, + recentPaths = currentForm.machineId?.let { stored.recentPaths[it] }.orEmpty(), + directoryStatus = directoryStatus, + agents = AgentFlavor.CREATABLE.map { OptionItem(it.id, Flavors.label(it.id)) }, + modelOptions = modelOptions, + modelsLoading = agent == "codex" && codex is CodexModelsUi.Loading, + modelsError = (codex as? CodexModelsUi.Failed)?.message?.let { strings.modelsFailedDetail.format(it) }, + effortOptions = if (agent == "claude") NewSessionCatalogs.CLAUDE_EFFORTS else null, + reasoningEffortOptions = reasoningEffortOptions, + permission = permission, + showCollaborationMode = agent == "codex", + collaborationModeOptions = CodexCollaborationMode.entries.map { OptionItem(it.wireId, it.label) }, + showFastMode = showFastMode, + showCopilotAgentMode = agent == "copilot", + copilotAgentModeOptions = CopilotAgentMode.entries.map { OptionItem(it.wireId, it.label) }, + worktreeNameError = nameError, + isSpawning = flags.isSpawning, + spawnError = flags.spawnError, + canCreate = currentForm.machineId != null && + trimmed.isNotEmpty() && + !flags.isSpawning && + !missingWorktreeDirectory && + nameError == null && + !codexValidationPending, + confirmCreateDirectory = flags.confirmed && needsCreationWarning, + ) + } + + companion object { + const val MSG_WORKTREE_MISSING = "Worktree sessions require an existing repository directory." + const val MSG_DIRECTORY_MISSING = "Directory does not exist. Creating the session will create it automatically." + const val MSG_DIRECTORY_MISSING_CONFIRM = "Directory does not exist. Tap Create again to create it automatically." + + /** `resolveHapiYoloPermissionMode` (`shared/src/agentConfig.ts`). */ + fun hapiYoloNativeMode(flavor: String?): PermissionMode? = when (flavor) { + "claude", "grok" -> PermissionMode.BypassPermissions + "agy" -> PermissionMode.AlwaysProceed + "codex", "copilot", "cursor", "gemini", "kimi", "opencode" -> PermissionMode.Yolo + else -> null + } + + /** `getMachineOptionLabel` (web `MachineSelector`), minus capability-skew (TODO). */ + fun machineOption(machine: Machine): MachineOptionUi { + val metadata = machine.metadata + val title = metadata?.displayName?.takeIf { it.isNotBlank() } + ?: metadata?.host + ?: machine.id.take(8) + val platform = metadata?.platform?.let { " ($it)" }.orEmpty() + val version = metadata?.happyCliVersion?.let { " · CLI $it" }.orEmpty() + val health = machine.health?.let { health -> + listOfNotNull( + health.cpuPercent?.let { "CPU ${it.toInt()}%" }, + health.memoryPercent?.let { "Mem ${it.toInt()}%" }, + ).joinToString(" · ").ifEmpty { null } + } + return MachineOptionUi(id = machine.id, label = "$title$platform$version", healthLabel = health) + } + + /** `formatRunnerSpawnError` (`web/src/utils/formatRunnerSpawnError.ts`). */ + fun formatRunnerSpawnError(machine: Machine?): String? { + val lastSpawnError = machine?.runnerState?.lastSpawnError.objOrNull ?: return null + val message = lastSpawnError["message"].stringOrNull?.takeIf { it.isNotEmpty() } ?: return null + val at = lastSpawnError["at"].longOrNull + return if (at != null) { + "$message (${DateFormat.getDateTimeInstance().format(Date(at))})" + } else { + message + } + } + } +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/pairing/ManualEntryScreen.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/pairing/ManualEntryScreen.kt new file mode 100644 index 0000000000..e091705b94 --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/pairing/ManualEntryScreen.kt @@ -0,0 +1,116 @@ +package app.hapi.companion.feature.pairing + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import app.hapi.companion.R + +/** + * Manual fallback for `--relay`-less local hubs: hub URL + access token, + * paste-friendly (plain single-line fields, no autocorrect/capitalization, + * the token is not masked — it is a pairing code, not a password). + */ +@Composable +fun ManualEntryScreen( + state: PairingUiState, + onPair: (hubUrl: String, accessToken: String) -> Unit, + onBack: () -> Unit, + onDismissError: () -> Unit, +) { + var hubUrl by rememberSaveable { mutableStateOf("") } + var accessToken by rememberSaveable { mutableStateOf("") } + val validating = state is PairingUiState.Validating + + Surface(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .safeDrawingPadding() + .verticalScroll(rememberScrollState()) + .padding(24.dp), + verticalArrangement = Arrangement.Center, + ) { + Text( + text = stringResource(R.string.pairing_manual_title), + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.pairing_manual_hint), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(24.dp)) + + OutlinedTextField( + value = hubUrl, + onValueChange = { hubUrl = it }, + label = { Text(stringResource(R.string.pairing_hub_url_label)) }, + placeholder = { Text(stringResource(R.string.pairing_hub_url_placeholder)) }, + singleLine = true, + enabled = !validating, + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false, + keyboardType = KeyboardType.Uri, + ), + modifier = Modifier.fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(12.dp)) + OutlinedTextField( + value = accessToken, + onValueChange = { accessToken = it }, + label = { Text(stringResource(R.string.pairing_token_label)) }, + placeholder = { Text(stringResource(R.string.pairing_token_placeholder)) }, + singleLine = true, + enabled = !validating, + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false, + keyboardType = KeyboardType.Ascii, + ), + modifier = Modifier.fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(24.dp)) + + PairingStatus(state = state, onDismissError = onDismissError) + + Button( + onClick = { onPair(hubUrl, accessToken) }, + enabled = !validating && hubUrl.isNotBlank() && accessToken.isNotBlank(), + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.pairing_pair_button)) + } + Spacer(modifier = Modifier.height(8.dp)) + TextButton(onClick = onBack, enabled = !validating, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.pairing_back)) + } + } + } +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/pairing/PairingClient.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/pairing/PairingClient.kt new file mode 100644 index 0000000000..56302292da --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/pairing/PairingClient.kt @@ -0,0 +1,23 @@ +package app.hapi.companion.feature.pairing + +import app.hapi.protocol.wire.AuthResponse +import app.hapi.protocol.wire.HubHealthResponse + +/** + * The two unauthenticated endpoints pairing needs, as a seam so + * [PairingViewModel] is unit-testable without OkHttp. Production adapts + * `HapiApi` (see `AppGraph.pairingClientFactory`); both methods throw + * `ApiError` on non-2xx and `IOException` when the hub is unreachable. + */ +interface PairingClient { + /** `GET /health` — reachability + `protocolVersion` probe. */ + suspend fun health(): HubHealthResponse + + /** `POST /api/auth` — access-token → JWT exchange; 401 = token rejected. */ + suspend fun authenticate(accessToken: String): AuthResponse +} + +/** Builds a [PairingClient] for one candidate hub (normalized origin). */ +fun interface PairingClientFactory { + fun create(hubUrl: String): PairingClient +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/pairing/PairingScreen.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/pairing/PairingScreen.kt new file mode 100644 index 0000000000..cacb1c250b --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/pairing/PairingScreen.kt @@ -0,0 +1,244 @@ +package app.hapi.companion.feature.pairing + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import app.hapi.companion.R + +/** + * Pairing landing: explains the self-hosted model, offers Scan QR / Manual + * entry, renders the deep-link confirm card ([prefill]) and the "why am I + * here" [notice] banner (auth-terminal sign-outs, bad links). + */ +@Composable +fun PairingScreen( + state: PairingUiState, + prefill: BindPrefill?, + notice: String?, + onDismissNotice: () -> Unit, + onScanQr: () -> Unit, + onManualEntry: () -> Unit, + onPairPrefill: () -> Unit, + onSwitchToPrefilledHub: () -> Unit, + onDismissPrefill: () -> Unit, + onDismissError: () -> Unit, +) { + Surface(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .safeDrawingPadding() + .verticalScroll(rememberScrollState()) + .padding(24.dp), + verticalArrangement = Arrangement.Center, + ) { + if (notice != null) { + NoticeCard(text = notice, onDismiss = onDismissNotice) + Spacer(modifier = Modifier.height(16.dp)) + } + + Text( + text = stringResource(R.string.pairing_title), + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = stringResource(R.string.pairing_intro), + style = MaterialTheme.typography.bodyLarge, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.pairing_hint), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(24.dp)) + + if (prefill != null) { + PrefillConfirmCard( + prefill = prefill, + validating = state is PairingUiState.Validating, + onPair = onPairPrefill, + onSwitch = onSwitchToPrefilledHub, + onDismiss = onDismissPrefill, + ) + Spacer(modifier = Modifier.height(16.dp)) + } + + PairingStatus(state = state, onDismissError = onDismissError) + + Button( + onClick = onScanQr, + enabled = state !is PairingUiState.Validating, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.pairing_scan_qr)) + } + Spacer(modifier = Modifier.height(8.dp)) + OutlinedButton( + onClick = onManualEntry, + enabled = state !is PairingUiState.Validating, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.pairing_manual_entry)) + } + } + } +} + +/** Shared "validating…" / error block used by all three pairing screens. */ +@Composable +internal fun PairingStatus( + state: PairingUiState, + onDismissError: () -> Unit, + modifier: Modifier = Modifier, +) { + when (state) { + is PairingUiState.Validating -> { + Row( + modifier = modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) + Text( + text = stringResource(R.string.pairing_validating), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(start = 12.dp), + ) + } + } + is PairingUiState.Error -> { + Card( + modifier = modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + ), + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text(text = pairingErrorText(state.error), style = MaterialTheme.typography.bodyMedium) + TextButton(onClick = onDismissError, modifier = Modifier.align(Alignment.End)) { + Text(stringResource(R.string.pairing_error_dismiss)) + } + } + } + } + else -> Unit + } +} + +@Composable +private fun NoticeCard(text: String, onDismiss: () -> Unit) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.tertiaryContainer, + contentColor = MaterialTheme.colorScheme.onTertiaryContainer, + ), + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text(text = text, style = MaterialTheme.typography.bodyMedium) + TextButton(onClick = onDismiss, modifier = Modifier.align(Alignment.End)) { + Text(stringResource(R.string.pairing_notice_dismiss)) + } + } + } +} + +@Composable +private fun PrefillConfirmCard( + prefill: BindPrefill, + validating: Boolean, + onPair: () -> Unit, + onSwitch: () -> Unit, + onDismiss: () -> Unit, +) { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = stringResource(R.string.pairing_confirm_title), + style = MaterialTheme.typography.titleMedium, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = prefill.hubUrl, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.primary, + ) + if (prefill.alreadyPaired) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.pairing_confirm_already), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(modifier = Modifier.height(16.dp)) + if (prefill.alreadyPaired) { + Button(onClick = onSwitch, enabled = !validating, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.pairing_confirm_switch)) + } + Spacer(modifier = Modifier.height(8.dp)) + OutlinedButton(onClick = onPair, enabled = !validating, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.pairing_confirm_repair)) + } + } else { + Button(onClick = onPair, enabled = !validating, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.pairing_confirm_pair)) + } + } + TextButton(onClick = onDismiss, enabled = !validating, modifier = Modifier.align(Alignment.End)) { + Text(stringResource(R.string.pairing_confirm_cancel)) + } + } + } +} + +/** Localize a [PairingError] (B-M5a). */ +@Composable +internal fun pairingErrorText(error: PairingError): String = when (error) { + PairingError.InvalidUrl -> stringResource(R.string.pairing_error_invalid_url) + PairingError.EmptyToken -> stringResource(R.string.pairing_error_empty_token) + PairingError.TokenRejected -> stringResource(R.string.pairing_error_token_rejected) + PairingError.HubGone -> stringResource(R.string.pairing_error_hub_gone) + is PairingError.Unreachable -> stringResource(R.string.pairing_error_unreachable, error.hubUrl) + is PairingError.NotAHub -> stringResource(R.string.pairing_error_not_a_hub, error.hubUrl) + is PairingError.ProtocolMismatch -> stringResource( + if (error.hubVersion > error.supportedVersion) { + R.string.pairing_error_protocol_update_app + } else { + R.string.pairing_error_protocol_update_hub + }, + error.hubVersion, + error.supportedVersion, + ) + is PairingError.AuthFailed -> stringResource(R.string.pairing_error_auth_failed, error.httpStatus) +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/pairing/PairingViewModel.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/pairing/PairingViewModel.kt new file mode 100644 index 0000000000..269c1d5112 --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/pairing/PairingViewModel.kt @@ -0,0 +1,198 @@ +package app.hapi.companion.feature.pairing + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.hapi.data.api.ApiError +import app.hapi.data.auth.CredentialStore +import app.hapi.data.auth.HubCredentials +import app.hapi.data.auth.HubRegistry +import app.hapi.data.auth.HubUrls +import app.hapi.protocol.pairing.BindLink +import app.hapi.protocol.wire.SUPPORTED_PROTOCOL_VERSION +import java.io.IOException +import kotlin.coroutines.cancellation.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** Pairing progress; one attempt at a time. */ +sealed interface PairingUiState { + data object Idle : PairingUiState + data object Validating : PairingUiState + data class Error(val error: PairingError) : PairingUiState + + /** Credentials stored, hub registered + active — navigate home. */ + data class Success(val hubUrl: String) : PairingUiState +} + +/** + * Semantic pairing failures (B-M5a): the ViewModel stays string-free and JVM + * tests assert these directly; the pairing screens localize them. + */ +sealed interface PairingError { + data object InvalidUrl : PairingError + data object EmptyToken : PairingError + data object TokenRejected : PairingError + + /** Deep-link "switch hub" raced a sign-out — that hub left the roster. */ + data object HubGone : PairingError + + data class Unreachable(val hubUrl: String) : PairingError + data class NotAHub(val hubUrl: String) : PairingError + data class ProtocolMismatch(val hubVersion: Int, val supportedVersion: Int) : PairingError + data class AuthFailed(val httpStatus: Int) : PairingError +} + +/** A `hapicompanion://bind` deep link waiting for user confirmation. */ +data class BindPrefill( + /** Normalized hub origin. */ + val hubUrl: String, + val accessToken: String, + /** True when this hub is already in the roster (offer switch/re-pair). */ + val alreadyPaired: Boolean, +) + +/** + * Drives one pairing attempt (`docs/api/client-contract/auth.md#pairing`): + * normalize the URL → `GET /health` reachability + protocol check → + * `POST /api/auth` exchange → persist [HubCredentials] → register + activate + * in the [HubRegistry] → [PairingUiState.Success]. + * + * Shared by the landing / QR-scan / manual-entry destinations (scoped to the + * pairing nav graph), so an attempt started from a scan result reports into + * the same state the other screens render. + */ +class PairingViewModel( + private val clientFactory: PairingClientFactory, + private val credentialStore: CredentialStore, + private val registry: HubRegistry, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, + private val nowMs: () -> Long = System::currentTimeMillis, +) : ViewModel() { + + private val mutableState = MutableStateFlow(PairingUiState.Idle) + val state: StateFlow = mutableState.asStateFlow() + + private val mutablePrefill = MutableStateFlow(null) + val prefill: StateFlow = mutablePrefill.asStateFlow() + + /** Loads a deep link into the confirm card (replacing any previous one). */ + fun prefillFromLink(link: BindLink) { + val normalized = HubUrls.normalize(link.hubUrl) + if (normalized == null) { + mutableState.value = PairingUiState.Error(PairingError.InvalidUrl) + return + } + mutablePrefill.value = BindPrefill( + hubUrl = normalized, + accessToken = link.accessToken, + alreadyPaired = normalized in registry.state.value.hubs, + ) + } + + fun dismissPrefill() { + mutablePrefill.value = null + } + + /** Confirm the deep link: full pairing (also the "re-pair" choice). */ + fun pairFromPrefill() { + prefill.value?.let { pair(it.hubUrl, it.accessToken) } + } + + /** "Already paired / switch hub" choice: keep credentials, just activate. */ + fun switchToPrefilledHub() { + val target = prefill.value ?: return + viewModelScope.launch { + mutableState.value = if (registry.setActiveHub(target.hubUrl)) { + PairingUiState.Success(target.hubUrl) + } else { + // Roster changed under us (sign-out race): fall back to pairing. + PairingUiState.Error(PairingError.HubGone) + } + } + } + + /** Starts a pairing attempt; no-op while one is already validating. */ + fun pair(hubUrl: String, accessToken: String) { + if (mutableState.value == PairingUiState.Validating) return + val normalized = HubUrls.normalize(hubUrl) + if (normalized == null) { + mutableState.value = PairingUiState.Error(PairingError.InvalidUrl) + return + } + // Trim whitespace only; the token stays opaque otherwise (never split + // client-side — the `:namespace` suffix belongs to the hub). + val token = accessToken.trim() + if (token.isEmpty()) { + mutableState.value = PairingUiState.Error(PairingError.EmptyToken) + return + } + mutableState.value = PairingUiState.Validating + viewModelScope.launch { + mutableState.value = runPairing(normalized, token) + } + } + + /** Error → Idle (retry affordance); other states are left alone. */ + fun dismissError() { + if (mutableState.value is PairingUiState.Error) { + mutableState.value = PairingUiState.Idle + } + } + + private suspend fun runPairing(hubUrl: String, accessToken: String): PairingUiState { + val client = clientFactory.create(hubUrl) + + val health = try { + client.health() + } catch (cancellation: CancellationException) { + throw cancellation + } catch (_: IOException) { + return PairingUiState.Error(PairingError.Unreachable(hubUrl)) + } catch (_: Exception) { + // Non-2xx or a body that is not the health schema: something + // answered, but it does not look like a HAPI hub. + return PairingUiState.Error(PairingError.NotAHub(hubUrl)) + } + if (health.protocolVersion != SUPPORTED_PROTOCOL_VERSION) { + return PairingUiState.Error( + PairingError.ProtocolMismatch(health.protocolVersion, SUPPORTED_PROTOCOL_VERSION), + ) + } + + val auth = try { + client.authenticate(accessToken) + } catch (cancellation: CancellationException) { + throw cancellation + } catch (error: ApiError) { + return if (error.status == 401) { + PairingUiState.Error(PairingError.TokenRejected) + } else { + PairingUiState.Error(PairingError.AuthFailed(error.status)) + } + } catch (_: Exception) { + return PairingUiState.Error(PairingError.Unreachable(hubUrl)) + } + + // Credentials first, then the roster: the active-hub observer builds a + // HubGraph as soon as the registry flips, and it must find the secret. + withContext(ioDispatcher) { + credentialStore.set( + HubCredentials( + hubUrl = hubUrl, + accessToken = accessToken, + jwt = auth.token, + jwtObtainedAtMs = nowMs(), + ) + ) + } + registry.addHub(hubUrl, makeActive = true) + mutablePrefill.value = null + return PairingUiState.Success(hubUrl) + } + +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/pairing/PortraitCaptureActivity.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/pairing/PortraitCaptureActivity.kt new file mode 100644 index 0000000000..346551226c --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/pairing/PortraitCaptureActivity.kt @@ -0,0 +1,11 @@ +package app.hapi.companion.feature.pairing + +import com.journeyapps.barcodescanner.CaptureActivity + +/** + * zxing-android-embedded pins its stock `CaptureActivity` to sensorLandscape + * in the library manifest; this empty subclass gets its own manifest entry + * with `screenOrientation="portrait"` so the pairing scan matches the rest of + * the (portrait) flow. Selected via `ScanOptions.setCaptureActivity`. + */ +class PortraitCaptureActivity : CaptureActivity() diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/pairing/QrScanScreen.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/pairing/QrScanScreen.kt new file mode 100644 index 0000000000..16efc92e65 --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/pairing/QrScanScreen.kt @@ -0,0 +1,184 @@ +package app.hapi.companion.feature.pairing + +import android.Manifest +import android.content.pm.PackageManager +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import app.hapi.companion.R +import app.hapi.protocol.pairing.BindLink +import app.hapi.protocol.pairing.PairingLinks +import com.journeyapps.barcodescanner.ScanContract +import com.journeyapps.barcodescanner.ScanOptions + +/** + * QR pairing via zxing-android-embedded's [ScanContract] (the full-screen + * capture activity as an Activity Result — simpler than embedding + * `DecoratedBarcodeView`). Entering the screen requests the CAMERA permission + * through the Activity Result API and then launches the scanner; scans are + * parsed with [PairingLinks.parse], so both the companion deeplink QR and the + * hub's web-URL QR pair successfully, and anything else gets a friendly + * "not a pairing code" retry card. + */ +@Composable +fun QrScanScreen( + state: PairingUiState, + onPairLink: (BindLink) -> Unit, + onManualEntry: () -> Unit, + onBack: () -> Unit, + onDismissError: () -> Unit, +) { + val context = LocalContext.current + var notPairingQr by rememberSaveable { mutableStateOf(false) } + var permissionDenied by rememberSaveable { mutableStateOf(false) } + val scanPrompt = stringResource(R.string.pairing_scan_prompt) + + val scanLauncher = rememberLauncherForActivityResult(ScanContract()) { result -> + val contents = result.contents + if (contents == null) { + // Back/cancel inside the capture activity: leave the scan screen too. + onBack() + } else { + val link = PairingLinks.parse(contents) + if (link == null) notPairingQr = true else onPairLink(link) + } + } + + fun launchScanner() { + notPairingQr = false + scanLauncher.launch( + ScanOptions().apply { + setDesiredBarcodeFormats(ScanOptions.QR_CODE) + setPrompt(scanPrompt) + setBeepEnabled(false) + setOrientationLocked(true) + setCaptureActivity(PortraitCaptureActivity::class.java) + } + ) + } + + val permissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { granted -> + if (granted) { + permissionDenied = false + launchScanner() + } else { + permissionDenied = true + } + } + + fun startScan() { + val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == + PackageManager.PERMISSION_GRANTED + if (granted) launchScanner() else permissionLauncher.launch(Manifest.permission.CAMERA) + } + + // Straight into the scanner on entry — unless something (an error card, + // a denied permission, a running attempt) is already on screen. + LaunchedEffect(Unit) { + if (state is PairingUiState.Idle && !notPairingQr && !permissionDenied) startScan() + } + + Surface(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .safeDrawingPadding() + .verticalScroll(rememberScrollState()) + .padding(24.dp), + verticalArrangement = Arrangement.Center, + ) { + Text( + text = stringResource(R.string.pairing_scan_title), + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.height(24.dp)) + + PairingStatus(state = state, onDismissError = onDismissError) + + if (notPairingQr) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + ), + ) { + Text( + text = stringResource(R.string.pairing_scan_not_pairing_qr), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(16.dp), + ) + } + } + if (permissionDenied) { + Text( + text = stringResource(R.string.pairing_scan_permission_rationale), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 16.dp), + ) + } + + Button( + onClick = ::startScan, + enabled = state !is PairingUiState.Validating, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + stringResource( + if (permissionDenied) R.string.pairing_scan_grant_camera + else R.string.pairing_scan_again + ) + ) + } + Spacer(modifier = Modifier.height(8.dp)) + OutlinedButton( + onClick = onManualEntry, + enabled = state !is PairingUiState.Validating, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.pairing_manual_entry)) + } + TextButton( + onClick = onBack, + enabled = state !is PairingUiState.Validating, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.pairing_back)) + } + } + } +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/scratchlist/AttachmentImport.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/scratchlist/AttachmentImport.kt new file mode 100644 index 0000000000..485d1cf4e5 --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/scratchlist/AttachmentImport.kt @@ -0,0 +1,145 @@ +package app.hapi.companion.feature.scratchlist + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import android.provider.OpenableColumns +import app.hapi.data.store.ScratchlistAttachmentGuard +import app.hapi.protocol.wire.ScratchlistAttachment +import app.hapi.protocol.wire.ScratchlistAttachmentLimits +import java.io.ByteArrayOutputStream +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * Production [ScratchlistAttachmentImporter]: reads the picked content URI, + * runs [ScratchlistAttachmentGuard] against the hub budgets, and downscales + * oversized raster images to JPEG (halving dimensions / stepping quality until + * the verdict's byte target fits). Pure limit decisions live in the guard + * (JVM-tested); this class only executes them with Android bitmap plumbing. + */ +class ContentResolverAttachmentImporter( + private val context: Context, +) : ScratchlistAttachmentImporter { + + override suspend fun import( + uri: Uri, + limits: ScratchlistAttachmentLimits, + existing: List, + ): ScratchlistImportOutcome = withContext(Dispatchers.IO) { + val resolver = context.contentResolver + val mimeType = resolver.getType(uri) ?: "application/octet-stream" + val bytes = try { + resolver.openInputStream(uri)?.use { it.readBytes() } + } catch (_: Exception) { + null + } ?: return@withContext ScratchlistImportOutcome.Rejected(ScratchlistImportRejection.Unreadable) + + val filename = displayNameOf(uri) ?: fallbackName(mimeType) + + when (val verdict = ScratchlistAttachmentGuard.evaluate(bytes.size.toLong(), mimeType, existing, limits)) { + ScratchlistAttachmentGuard.Verdict.Fits -> + ScratchlistImportOutcome.Ready(PreparedScratchlistAttachment(filename, bytes, mimeType)) + + is ScratchlistAttachmentGuard.Verdict.Downscale -> { + val compressed = downscaleToJpeg(bytes, verdict.targetBytes) + if (compressed == null) { + ScratchlistImportOutcome.Rejected(ScratchlistImportRejection.ImageTooLarge) + } else { + ScratchlistImportOutcome.Ready( + PreparedScratchlistAttachment(jpegName(filename), compressed, "image/jpeg") + ) + } + } + + is ScratchlistAttachmentGuard.Verdict.Reject -> + ScratchlistImportOutcome.Rejected(rejectionOf(verdict.reason, limits)) + } + } + + private fun displayNameOf(uri: Uri): String? = try { + context.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null) + ?.use { cursor -> + val index = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (index >= 0 && cursor.moveToFirst()) cursor.getString(index) else null + } + } catch (_: Exception) { + null + }?.takeIf { it.isNotBlank() } + + private companion object { + /** Decode budget before quality stepping (≈ 4K-screen worth of pixels). */ + const val MAX_DECODE_PIXELS = 2048 * 2048 + const val MIN_DIMENSION = 64 + val QUALITY_STEPS = intArrayOf(90, 80, 70, 60, 50) + + fun fallbackName(mimeType: String): String = when { + mimeType.startsWith("image/") -> "photo-${System.currentTimeMillis()}.${mimeType.substringAfter('/')}" + else -> "attachment-${System.currentTimeMillis()}" + } + + fun jpegName(original: String): String = + original.substringBeforeLast('.', original).ifBlank { "photo" } + ".jpg" + + fun rejectionOf( + reason: ScratchlistAttachmentGuard.Reason, + limits: ScratchlistAttachmentLimits, + ): ScratchlistImportRejection = when (reason) { + ScratchlistAttachmentGuard.Reason.TooManyForEntry -> + ScratchlistImportRejection.TooManyAttachments(limits.maxAttachmentsPerEntry) + ScratchlistAttachmentGuard.Reason.MimeNotAllowed -> + ScratchlistImportRejection.FileTypeNotAllowed + ScratchlistAttachmentGuard.Reason.TooLarge -> + ScratchlistImportRejection.FileTooLarge(limits.maxBytesPerFile / (1024 * 1024)) + ScratchlistAttachmentGuard.Reason.EntryBudgetExhausted -> + ScratchlistImportRejection.BudgetExhausted + } + + /** + * Re-encode [source] as JPEG under [targetBytes]: sampled decode, then + * quality steps, then dimension halving — null when even a tiny + * re-encode stays over budget (pathological targets). + */ + fun downscaleToJpeg(source: ByteArray, targetBytes: Long): ByteArray? { + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeByteArray(source, 0, source.size, bounds) + if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null + + var sampleSize = 1 + while ( + (bounds.outWidth / sampleSize).toLong() * (bounds.outHeight / sampleSize) > MAX_DECODE_PIXELS + ) { + sampleSize *= 2 + } + var bitmap = BitmapFactory.decodeByteArray( + source, + 0, + source.size, + BitmapFactory.Options().apply { inSampleSize = sampleSize }, + ) ?: return null + + try { + while (true) { + for (quality in QUALITY_STEPS) { + val out = ByteArrayOutputStream() + bitmap.compress(Bitmap.CompressFormat.JPEG, quality, out) + val candidate = out.toByteArray() + if (candidate.size <= targetBytes) return candidate + } + if (bitmap.width <= MIN_DIMENSION || bitmap.height <= MIN_DIMENSION) return null + val halved = Bitmap.createScaledBitmap( + bitmap, + (bitmap.width / 2).coerceAtLeast(1), + (bitmap.height / 2).coerceAtLeast(1), + true, + ) + if (halved !== bitmap) bitmap.recycle() + bitmap = halved + } + } finally { + bitmap.recycle() + } + } + } +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/scratchlist/ScratchlistScreen.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/scratchlist/ScratchlistScreen.kt new file mode 100644 index 0000000000..d9d69d3a34 --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/scratchlist/ScratchlistScreen.kt @@ -0,0 +1,636 @@ +package app.hapi.companion.feature.scratchlist + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.OutlinedTextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import android.content.Context +import app.hapi.companion.R +import app.hapi.companion.feature.sessions.localizedRelativeAge +import app.hapi.companion.ui.theme.HapiTheme +import app.hapi.companion.ui.theme.hapi +import app.hapi.protocol.wire.ScratchlistAttachment +import app.hapi.protocol.wire.ScratchlistEntry +import coil.ImageLoader +import coil.compose.AsyncImage + +/** + * Hub-scoped media plumbing for scratchlist attachments: the authed Coil + * loader plus the attachment URL builder (both from `HubGraph`). Null loader + * (previews, tests) degrades thumbnails to filename chips. + */ +data class ScratchlistMedia( + val imageLoader: ImageLoader?, + val attachmentUrl: (attachmentId: String) -> String?, +) + +private fun isImageMime(mimeType: String): Boolean = mimeType.startsWith("image/") + +/** + * Per-session scratchlist workbench (B-M4d): notes/drafts parked until the + * operator promotes them. Entry cards (text preview, age, attachment thumbs) + * open an edit sheet; the FAB drafts a new note; "To composer" inserts an + * entry's text into the chat composer (wired by Navigation to the chat + * ViewModel below this route). + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ScratchlistScreen( + viewModel: ScratchlistViewModel, + media: ScratchlistMedia, + onBack: () -> Unit, + modifier: Modifier = Modifier, + /** null ⇒ the affordance is hidden (no chat composer below this route). */ + onSendToComposer: ((ScratchlistEntry) -> Unit)? = null, +) { + val state by viewModel.uiState.collectAsState() + val editorState by viewModel.editor.collectAsState() + val snackbarHostState = remember { SnackbarHostState() } + var viewerAttachment by remember { mutableStateOf(null) } + + DisposableEffect(viewModel) { + viewModel.start() + onDispose { viewModel.stop() } + } + + val context = LocalContext.current + LaunchedEffect(viewModel, context) { + viewModel.events.collect { event -> + when (event) { + is ScratchlistEvent.Notice -> + snackbarHostState.showSnackbar(scratchlistNoticeText(context, event.notice)) + } + } + } + + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + TopAppBar( + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.scratchlist_back)) + } + }, + title = { + Column { + Text(stringResource(R.string.scratchlist_title), style = MaterialTheme.typography.titleMedium) + Text( + text = when { + !state.isLoading && state.entries.isEmpty() -> + stringResource(R.string.scratchlist_count_none) + state.entries.size == 1 -> stringResource(R.string.scratchlist_count_one) + else -> stringResource(R.string.scratchlist_count_many, state.entries.size) + }, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + floatingActionButton = { + FloatingActionButton(onClick = { viewModel.openEditor(null) }) { + Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.scratchlist_new_note)) + } + }, + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding), + ) { + if (state.uploadsInFlight.isNotEmpty()) { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } + when { + state.isLoading -> CenteredHint { CircularProgressIndicator() } + state.loadFailed -> LoadFailed(onRetry = viewModel::retry) + state.entries.isEmpty() -> EmptyScratchlist() + else -> LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + items(state.entries, key = { it.entryId }) { entry -> + ScratchlistEntryCard( + entry = entry, + media = media, + onOpen = { viewModel.openEditor(entry) }, + onSendToComposer = onSendToComposer?.let { send -> { send(entry) } }, + onOpenAttachment = { viewerAttachment = it }, + ) + } + } + } + } + } + + editorState?.let { editor -> + ScratchlistEditorSheet( + editor = editor, + media = media, + onDismiss = viewModel::dismissEditor, + onTextChange = viewModel::setEditorText, + onSave = viewModel::saveEditor, + onDelete = editor.entryId?.let { id -> { viewModel.deleteEntry(id) } }, + onAddAttachment = viewModel::addAttachment, + onRemoveAttachment = viewModel::removeAttachment, + onOpenAttachment = { viewerAttachment = it }, + ) + } + + viewerAttachment?.let { attachment -> + AttachmentViewerDialog( + attachment = attachment, + media = media, + onDismiss = { viewerAttachment = null }, + ) + } +} + +// ------------------------------------------------------------------- card -- + +@Composable +private fun ScratchlistEntryCard( + entry: ScratchlistEntry, + media: ScratchlistMedia, + onOpen: () -> Unit, + onSendToComposer: (() -> Unit)?, + onOpenAttachment: (ScratchlistAttachment) -> Unit, +) { + Surface( + color = MaterialTheme.colorScheme.surfaceContainerLow, + shape = RoundedCornerShape(14.dp), + tonalElevation = 1.dp, + onClick = onOpen, + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp)) { + if (entry.attachments.isNotEmpty()) { + AttachmentStrip( + attachments = entry.attachments, + media = media, + thumbSize = 64.dp, + onOpenAttachment = onOpenAttachment, + ) + Spacer(modifier = Modifier.height(6.dp)) + } + if (entry.text.isNotBlank()) { + Text( + text = entry.text, + style = MaterialTheme.typography.bodyMedium, + maxLines = 4, + overflow = TextOverflow.Ellipsis, + ) + } else { + Text( + text = stringResource(R.string.scratchlist_attachment_only), + style = MaterialTheme.typography.bodyMedium, + fontStyle = FontStyle.Italic, + color = MaterialTheme.hapi.hint, + ) + } + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = localizedRelativeAge(entry.updatedAt), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.hapi.hint, + ) + Spacer(modifier = Modifier.weight(1f)) + if (onSendToComposer != null) { + TextButton(onClick = onSendToComposer) { + Text(stringResource(R.string.scratchlist_to_composer), style = MaterialTheme.typography.labelMedium) + } + } + } + } + } +} + +// ------------------------------------------------------------ attachments -- + +/** + * Horizontal thumbnails: images render through the authed Coil loader, other + * mime types (pdf/text) and loader-less previews degrade to filename chips. + */ +@Composable +private fun AttachmentStrip( + attachments: List, + media: ScratchlistMedia, + thumbSize: androidx.compose.ui.unit.Dp, + onOpenAttachment: (ScratchlistAttachment) -> Unit, + onRemoveAttachment: ((ScratchlistAttachment) -> Unit)? = null, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + attachments.forEach { attachment -> + AttachmentThumb( + attachment = attachment, + media = media, + thumbSize = thumbSize, + onOpen = { onOpenAttachment(attachment) }, + onRemove = onRemoveAttachment?.let { remove -> { remove(attachment) } }, + ) + } + } +} + +/** One thumbnail (image via Coil, otherwise a filename chip) with an optional ✕ badge. */ +@Composable +private fun AttachmentThumb( + attachment: ScratchlistAttachment, + media: ScratchlistMedia, + thumbSize: androidx.compose.ui.unit.Dp, + onOpen: () -> Unit, + onRemove: (() -> Unit)? = null, +) { + val url = media.attachmentUrl(attachment.id) + Box { + if (media.imageLoader != null && url != null && isImageMime(attachment.mimeType)) { + AsyncImage( + model = url, + imageLoader = media.imageLoader, + contentDescription = attachment.filename, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(thumbSize) + .clip(RoundedCornerShape(10.dp)) + .clickable { onOpen() }, + ) + } else { + Surface( + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = RoundedCornerShape(10.dp), + modifier = Modifier.size(thumbSize), + onClick = onOpen, + ) { + Box(contentAlignment = Alignment.Center, modifier = Modifier.padding(4.dp)) { + Text( + text = "📎 ${attachment.filename}", + style = MaterialTheme.typography.labelSmall, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + if (onRemove != null) { + Surface( + color = MaterialTheme.colorScheme.inverseSurface, + contentColor = MaterialTheme.colorScheme.inverseOnSurface, + shape = CircleShape, + onClick = onRemove, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(2.dp) + .size(20.dp), + ) { + Box(contentAlignment = Alignment.Center) { + Text(text = "✕", fontSize = 11.sp) + } + } + } + } +} + +// ----------------------------------------------------------------- editor -- + +/** + * Edit sheet: text field + attachment strip (photo picker, remove, spinner + * while a file imports/uploads) + Delete for existing entries + Save. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ScratchlistEditorSheet( + editor: ScratchlistEditorState, + media: ScratchlistMedia, + onDismiss: () -> Unit, + onTextChange: (String) -> Unit, + onSave: () -> Unit, + onDelete: (() -> Unit)?, + onAddAttachment: (android.net.Uri) -> Unit, + onRemoveAttachment: (ScratchlistAttachment) -> Unit, + onOpenAttachment: (ScratchlistAttachment) -> Unit, +) { + val pickImage = rememberLauncherForActivityResult( + ActivityResultContracts.PickVisualMedia(), + ) { uri -> uri?.let(onAddAttachment) } + + ModalBottomSheet(onDismissRequest = onDismiss) { + Column(modifier = Modifier.padding(horizontal = 16.dp)) { + Text( + text = stringResource( + if (editor.entryId == null) R.string.scratchlist_new_note else R.string.scratchlist_edit_note, + ), + style = MaterialTheme.typography.titleMedium, + ) + Spacer(modifier = Modifier.height(10.dp)) + OutlinedTextField( + value = editor.text, + onValueChange = onTextChange, + placeholder = { Text(stringResource(R.string.scratchlist_placeholder)) }, + minLines = 3, + maxLines = 8, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(10.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + editor.attachments.forEach { attachment -> + AttachmentThumb( + attachment = attachment, + media = media, + thumbSize = 72.dp, + onOpen = { onOpenAttachment(attachment) }, + onRemove = { onRemoveAttachment(attachment) }, + ) + } + if (editor.isUploading) { + Surface( + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = RoundedCornerShape(10.dp), + modifier = Modifier.size(72.dp), + ) { + Box(contentAlignment = Alignment.Center) { + CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) + } + } + } else { + Surface( + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = RoundedCornerShape(10.dp), + onClick = { + pickImage.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) + ) + }, + modifier = Modifier.size(72.dp), + ) { + Box(contentAlignment = Alignment.Center) { + Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.scratchlist_add_photo)) + } + } + } + } + Spacer(modifier = Modifier.height(12.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + if (onDelete != null) { + TextButton(onClick = onDelete) { + Text(stringResource(R.string.scratchlist_delete), color = MaterialTheme.colorScheme.error) + } + } + Spacer(modifier = Modifier.weight(1f)) + TextButton(onClick = onDismiss) { Text(stringResource(R.string.scratchlist_cancel)) } + Spacer(modifier = Modifier.width(6.dp)) + Button(onClick = onSave, enabled = !editor.isUploading) { + Text(stringResource(R.string.scratchlist_save)) + } + } + Spacer(modifier = Modifier.height(24.dp)) + } + } +} + +// ----------------------------------------------------------------- viewer -- + +/** Full-screen attachment viewer (the generated-image viewer pattern). */ +@Composable +private fun AttachmentViewerDialog( + attachment: ScratchlistAttachment, + media: ScratchlistMedia, + onDismiss: () -> Unit, +) { + val url = media.attachmentUrl(attachment.id) + if (media.imageLoader == null || url == null || !isImageMime(attachment.mimeType)) { + onDismiss() + return + } + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.92f)) + .clickable { onDismiss() }, + contentAlignment = Alignment.Center, + ) { + AsyncImage( + model = url, + imageLoader = media.imageLoader, + contentDescription = attachment.filename, + contentScale = ContentScale.Fit, + modifier = Modifier + .fillMaxSize() + .padding(8.dp), + ) + } + } +} + +// ----------------------------------------------------------------- states -- + +@Composable +private fun CenteredHint(content: @Composable () -> Unit) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + content() + } +} + +@Composable +private fun LoadFailed(onRetry: () -> Unit) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(32.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(text = stringResource(R.string.scratchlist_load_failed), style = MaterialTheme.typography.titleMedium) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.scratchlist_check_connection), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(12.dp)) + TextButton(onClick = onRetry) { Text(stringResource(R.string.scratchlist_retry)) } + } +} + +@Composable +private fun EmptyScratchlist() { + Column( + modifier = Modifier + .fillMaxSize() + .padding(32.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(text = "🗒", fontSize = 34.sp) + Spacer(modifier = Modifier.height(8.dp)) + Text(text = stringResource(R.string.scratchlist_empty_title), style = MaterialTheme.typography.titleMedium) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.scratchlist_empty_hint), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +// -------------------------------------------------------------- previews -- + +private val previewEntry = ScratchlistEntry( + entryId = "e1", + text = "Try the alternative pagination cursor approach — ask the agent to benchmark both before committing.", + createdAt = 1_755_000_000_000, + updatedAt = 1_755_003_600_000, + attachments = listOf( + ScratchlistAttachment( + id = "a1", + filename = "sketch.png", + mimeType = "image/png", + size = 120_000, + path = "hapi-hub:scratchlist/a1", + ), + ), +) + +@Preview(showBackground = true) +@Composable +private fun ScratchlistEntryCardPreview() { + HapiTheme { + Surface { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + ScratchlistEntryCard( + entry = previewEntry, + media = ScratchlistMedia(imageLoader = null) { null }, + onOpen = {}, + onSendToComposer = {}, + onOpenAttachment = {}, + ) + ScratchlistEntryCard( + entry = previewEntry.copy(entryId = "e2", text = "", attachments = emptyList()), + media = ScratchlistMedia(imageLoader = null) { null }, + onOpen = {}, + onSendToComposer = null, + onOpenAttachment = {}, + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun EmptyScratchlistPreview() { + HapiTheme { + Surface { EmptyScratchlist() } + } +} + +// ------------------------------------------------------------- notices -- + +/** Localize a [ScratchlistNotice] (B-M5a). */ +internal fun scratchlistNoticeText(context: Context, notice: ScratchlistNotice): String = when (notice) { + ScratchlistNotice.AtCapDeleteFirst -> context.getString(R.string.scratchlist_notice_full_delete_first) + ScratchlistNotice.AtCap -> context.getString(R.string.scratchlist_notice_full) + ScratchlistNotice.NeedsContent -> context.getString(R.string.scratchlist_notice_needs_content) + ScratchlistNotice.SaveFailed -> context.getString(R.string.scratchlist_notice_save_failed) + ScratchlistNotice.DeleteFailed -> context.getString(R.string.scratchlist_notice_delete_failed) + ScratchlistNotice.AttachFailed -> context.getString(R.string.scratchlist_notice_attach_failed) + ScratchlistNotice.RemoveAttachmentFailed -> + context.getString(R.string.scratchlist_notice_remove_attachment_failed) + ScratchlistNotice.UploadTooLarge -> context.getString(R.string.scratchlist_notice_too_large) + ScratchlistNotice.UploadFailed -> context.getString(R.string.scratchlist_notice_upload_failed) + is ScratchlistNotice.ImportRejected -> when (val reason = notice.reason) { + ScratchlistImportRejection.Unreadable -> context.getString(R.string.scratchlist_reject_unreadable) + ScratchlistImportRejection.ImageTooLarge -> context.getString(R.string.scratchlist_reject_image_too_large) + is ScratchlistImportRejection.TooManyAttachments -> + context.getString(R.string.scratchlist_reject_max_attachments, reason.max) + ScratchlistImportRejection.FileTypeNotAllowed -> context.getString(R.string.scratchlist_reject_type) + is ScratchlistImportRejection.FileTooLarge -> + context.getString(R.string.scratchlist_reject_file_too_large, reason.maxMb) + ScratchlistImportRejection.BudgetExhausted -> context.getString(R.string.scratchlist_reject_budget) + } +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/scratchlist/ScratchlistViewModel.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/scratchlist/ScratchlistViewModel.kt new file mode 100644 index 0000000000..b6169e8406 --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/scratchlist/ScratchlistViewModel.kt @@ -0,0 +1,331 @@ +package app.hapi.companion.feature.scratchlist + +import android.net.Uri +import app.hapi.data.store.ScratchlistAttachmentDeleteResult +import app.hapi.data.store.ScratchlistCreateResult +import app.hapi.data.store.ScratchlistUploadResult +import app.hapi.data.store.SessionScratchlist +import app.hapi.protocol.wire.ScratchlistAttachment +import app.hapi.protocol.wire.ScratchlistAttachmentLimits +import app.hapi.protocol.wire.ScratchlistEntry +import app.hapi.protocol.wire.ScratchlistErrorCodes +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +/** What [ScratchlistScreen] renders as the entry list. */ +data class ScratchlistUiState( + val entries: List = emptyList(), + /** First fetch still running, nothing cached yet. */ + val isLoading: Boolean = true, + /** First fetch failed with nothing to show → error state. */ + val loadFailed: Boolean = false, + /** 200-entry cap reached — FAB/park surfaces a friendly message instead. */ + val atCap: Boolean = false, + val uploadsInFlight: List = emptyList(), +) + +/** Edit-sheet model; [entryId] `null` = drafting a brand-new entry. */ +data class ScratchlistEditorState( + val entryId: String? = null, + val text: String = "", + val attachments: List = emptyList(), + /** A picked file is importing/uploading — spinner chip in the strip. */ + val isUploading: Boolean = false, +) + +sealed interface ScratchlistEvent { + /** Transient failure/notice for a snackbar (resolved to a string at the UI layer). */ + data class Notice(val notice: ScratchlistNotice) : ScratchlistEvent +} + +/** Semantic scratchlist notices (B-M5a) — localized by the screen. */ +sealed interface ScratchlistNotice { + data object AtCapDeleteFirst : ScratchlistNotice + data object AtCap : ScratchlistNotice + data object NeedsContent : ScratchlistNotice + data object SaveFailed : ScratchlistNotice + data object DeleteFailed : ScratchlistNotice + data object AttachFailed : ScratchlistNotice + data object RemoveAttachmentFailed : ScratchlistNotice + data object UploadTooLarge : ScratchlistNotice + data object UploadFailed : ScratchlistNotice + data class ImportRejected(val reason: ScratchlistImportRejection) : ScratchlistNotice +} + +/** Why an attachment pick was rejected before upload (localized by the screen). */ +sealed interface ScratchlistImportRejection { + data object Unreadable : ScratchlistImportRejection + data object ImageTooLarge : ScratchlistImportRejection + data class TooManyAttachments(val max: Int) : ScratchlistImportRejection + data object FileTypeNotAllowed : ScratchlistImportRejection + data class FileTooLarge(val maxMb: Long) : ScratchlistImportRejection + data object BudgetExhausted : ScratchlistImportRejection +} + +/** Prepared upload payload produced by a [ScratchlistAttachmentImporter]. */ +class PreparedScratchlistAttachment( + val filename: String, + val bytes: ByteArray, + val mimeType: String, +) + +sealed interface ScratchlistImportOutcome { + data class Ready(val attachment: PreparedScratchlistAttachment) : ScratchlistImportOutcome + data class Rejected(val reason: ScratchlistImportRejection) : ScratchlistImportOutcome +} + +/** + * Reads a picked content [Uri] and enforces the attachment budgets + * (`ScratchlistAttachmentGuard`), downscaling oversized raster images. + * Production: [ContentResolverAttachmentImporter]; tests fake it. + */ +fun interface ScratchlistAttachmentImporter { + suspend fun import( + uri: Uri, + limits: ScratchlistAttachmentLimits, + existing: List, + ): ScratchlistImportOutcome +} + +/** + * Per-session scratchlist workbench (B-M4d): the entries list rides + * [SessionScratchlist]'s per-session cache (optimistic CRUD + SSE-triggered + * refetch handled there); this ViewModel owns the edit-sheet draft state and + * the attachment import→upload flow. + * + * Attachment writes: on an **existing** entry every strip change persists + * immediately (upload → `PUT {attachments}` / remove → PUT minus the file, + * then a best-effort attachment delete to free session bytes). On a **new** + * entry uploads accumulate locally and travel with the create; dismissing the + * draft best-effort deletes the now-orphaned uploads. + */ +class ScratchlistViewModel( + val sessionId: String, + private val store: SessionScratchlist, + private val scope: CoroutineScope, + private val importer: ScratchlistAttachmentImporter? = null, +) { + val uiState: StateFlow = store.state(sessionId) + .map { st -> + ScratchlistUiState( + entries = st.entries, + isLoading = !st.loaded && !st.loadFailed, + loadFailed = st.loadFailed, + atCap = st.atCap, + uploadsInFlight = st.uploadsInFlight, + ) + } + .stateIn(scope, SharingStarted.Eagerly, ScratchlistUiState()) + + private val _editor = MutableStateFlow(null) + + /** Non-null while the edit sheet is open. */ + val editor: StateFlow = _editor.asStateFlow() + + private val _events = MutableSharedFlow(extraBufferCapacity = 16) + val events: SharedFlow = _events.asSharedFlow() + + private var opened = false + + /** Idempotent; call from the screen's composition, paired with [stop]. */ + fun start() { + if (opened) return + opened = true + store.open(sessionId) + } + + fun stop() { + if (!opened) return + opened = false + store.release(sessionId) + } + + /** Error-state retry. */ + fun retry() { + scope.launch { runCatching { store.refresh(sessionId) } } + } + + // -------------------------------------------------------------- editor -- + + /** Card tap (existing) or FAB (`entry = null`, new draft). */ + fun openEditor(entry: ScratchlistEntry?) { + if (entry == null && uiState.value.atCap) { + notice(ScratchlistNotice.AtCapDeleteFirst) + return + } + _editor.value = if (entry == null) { + ScratchlistEditorState() + } else { + ScratchlistEditorState( + entryId = entry.entryId, + text = entry.text, + attachments = entry.attachments, + ) + } + } + + /** Sheet dismissed without saving; orphaned new-draft uploads are freed. */ + fun dismissEditor() { + val editor = _editor.value ?: return + _editor.value = null + if (editor.entryId == null && editor.attachments.isNotEmpty()) { + scope.launch { + editor.attachments.forEach { attachment -> + runCatching { store.deleteAttachment(sessionId, attachment.id) } + } + } + } + } + + fun setEditorText(text: String) { + _editor.value = _editor.value?.copy(text = text) + } + + /** + * Save closes the sheet immediately (mutations are store-optimistic and + * roll back with a snackbar on failure — web parity). + */ + fun saveEditor() { + val editor = _editor.value ?: return + val text = editor.text.trim() + if (text.isEmpty() && editor.attachments.isEmpty()) { + // Nothing to keep: a new draft just closes; an existing entry + // must keep text or attachments (hub 400s empty updates). + if (editor.entryId == null) { + _editor.value = null + } else { + notice(ScratchlistNotice.NeedsContent) + } + return + } + _editor.value = null + scope.launch { + if (editor.entryId == null) { + when (store.createEntry(sessionId, text, editor.attachments)) { + is ScratchlistCreateResult.Created -> Unit + ScratchlistCreateResult.AtCap -> notice(ScratchlistNotice.AtCap) + is ScratchlistCreateResult.Failed -> notice(ScratchlistNotice.SaveFailed) + } + } else { + if (!store.updateEntry(sessionId, editor.entryId, text = text)) { + notice(ScratchlistNotice.SaveFailed) + } + } + } + } + + /** Sheet delete (or a per-card affordance); optimistic with store rollback. */ + fun deleteEntry(entryId: String) { + if (_editor.value?.entryId == entryId) _editor.value = null + scope.launch { + if (!store.deleteEntry(sessionId, entryId)) { + notice(ScratchlistNotice.DeleteFailed) + } + } + } + + // --------------------------------------------------------- attachments -- + + /** Photo-picker result → guard/downscale → upload → strip (and PUT for existing entries). */ + fun addAttachment(uri: Uri) { + val importer = importer ?: return + val editor = _editor.value ?: return + if (editor.isUploading) return + _editor.value = editor.copy(isUploading = true) + scope.launch { + try { + val limits = store.limits(sessionId) + val current = _editor.value ?: return@launch + val outcome = importer.import(uri, limits, current.attachments) + val prepared = when (outcome) { + is ScratchlistImportOutcome.Rejected -> { + notice(ScratchlistNotice.ImportRejected(outcome.reason)) + return@launch + } + is ScratchlistImportOutcome.Ready -> outcome.attachment + } + val uploaded = store.uploadAttachment( + sessionId, + filename = prepared.filename, + bytes = prepared.bytes, + mimeType = prepared.mimeType, + ) + when (uploaded) { + is ScratchlistUploadResult.Failed -> notice(uploadFailureNotice(uploaded)) + is ScratchlistUploadResult.Uploaded -> attachToEditor(uploaded.attachment) + } + } finally { + _editor.value = _editor.value?.copy(isUploading = false) + } + } + } + + private suspend fun attachToEditor(attachment: ScratchlistAttachment) { + val editor = _editor.value ?: run { + // Sheet closed mid-upload: don't leak the stored file. + store.deleteAttachment(sessionId, attachment.id) + return + } + val next = editor.attachments + attachment + _editor.value = editor.copy(attachments = next) + if (editor.entryId != null) { + if (!store.updateEntry(sessionId, editor.entryId, attachments = next)) { + rollbackEditorAttachments(editor.entryId, editor.attachments) + runCatching { store.deleteAttachment(sessionId, attachment.id) } + notice(ScratchlistNotice.AttachFailed) + } + } + } + + /** Restore the strip to [attachments] if the sheet still edits [entryId]. */ + private fun rollbackEditorAttachments(entryId: String, attachments: List) { + val current = _editor.value ?: return + if (current.entryId == entryId) { + _editor.value = current.copy(attachments = attachments) + } + } + + /** Strip ✕: detach (existing entries PUT immediately) and free the stored file. */ + fun removeAttachment(attachment: ScratchlistAttachment) { + val editor = _editor.value ?: return + val next = editor.attachments.filter { it.id != attachment.id } + if (next.size == editor.attachments.size) return + _editor.value = editor.copy(attachments = next) + scope.launch { + if (editor.entryId != null) { + if (!store.updateEntry(sessionId, editor.entryId, attachments = next)) { + rollbackEditorAttachments(editor.entryId, editor.attachments) + notice(ScratchlistNotice.RemoveAttachmentFailed) + return@launch + } + } + // Best-effort byte-budget cleanup; InUse just means another entry + // still references the file (fine to leave). + when (store.deleteAttachment(sessionId, attachment.id)) { + ScratchlistAttachmentDeleteResult.Removed, + ScratchlistAttachmentDeleteResult.InUse, + -> Unit + is ScratchlistAttachmentDeleteResult.Failed -> Unit + } + } + } + + private fun uploadFailureNotice(failed: ScratchlistUploadResult.Failed): ScratchlistNotice = + when (failed.code) { + ScratchlistErrorCodes.ATTACHMENT_TOO_LARGE -> ScratchlistNotice.UploadTooLarge + else -> ScratchlistNotice.UploadFailed + } + + private fun notice(notice: ScratchlistNotice) { + _events.tryEmit(ScratchlistEvent.Notice(notice)) + } +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/sessions/RelativeTime.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/sessions/RelativeTime.kt new file mode 100644 index 0000000000..8744eef371 --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/sessions/RelativeTime.kt @@ -0,0 +1,37 @@ +package app.hapi.companion.feature.sessions + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import app.hapi.companion.R + +/** + * Compact relative-age label for list rows ("now", "5m", "3h", "2d"). + * Minute granularity is deliberate: it is why sub-minute `activeAt` churn can + * be dropped as render-irrelevant (`sse.md#keep-alive-noise`). + * + * The unit suffixes (m/h/d/w/mo/y) deliberately stay Latin across locales — + * timestamp-style shorthand, like the web's compact ages; only the sub-minute + * "now" word localizes (via [localizedRelativeAge]). + */ +fun formatRelativeAge(nowMs: Long, thenMs: Long): String { + val delta = nowMs - thenMs + if (delta < 60_000) return "now" + val minutes = delta / 60_000 + if (minutes < 60) return "${minutes}m" + val hours = minutes / 60 + if (hours < 24) return "${hours}h" + val days = hours / 24 + if (days < 7) return "${days}d" + val weeks = days / 7 + if (weeks < 5) return "${weeks}w" + val months = days / 30 + if (months < 12) return "${months}mo" + return "${days / 365}y" +} + +/** [formatRelativeAge] against the current clock, with "now" localized. */ +@Composable +fun localizedRelativeAge(thenMs: Long): String { + val raw = formatRelativeAge(System.currentTimeMillis(), thenMs) + return if (raw == "now") stringResource(R.string.sessions_age_now) else raw +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/sessions/ReopenErrors.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/sessions/ReopenErrors.kt new file mode 100644 index 0000000000..778da15117 --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/sessions/ReopenErrors.kt @@ -0,0 +1,29 @@ +package app.hapi.companion.feature.sessions + +import app.hapi.data.api.ApiError +import app.hapi.protocol.wire.arrayOrNull +import app.hapi.protocol.wire.objOrNull +import app.hapi.protocol.wire.stringOrNull +import kotlinx.serialization.json.Json + +/** + * Human-readable message for a reopen/resume failure (web + * `formatReopenError`, `reopenError.ts`): the hub 422s with + * `{error, missing: [...]}` when required metadata is gone (e.g. a Cursor + * session without `cursorSessionId`); other errors carry `{error, code?}`. + * Falls back to the raw exception message when the body is unparseable, and + * to null when there is no message at all — the UI layer then shows its + * localized "failed to reopen" fallback (B-M5a). + */ +fun formatReopenError(error: Exception): String? { + val fallback = error.message + val body = (error as? ApiError)?.body ?: return fallback + val parsed = try { + Json.parseToJsonElement(body).objOrNull + } catch (_: Exception) { + null + } ?: return fallback + val message = parsed["error"].stringOrNull ?: return fallback + val missing = parsed["missing"].arrayOrNull?.mapNotNull { it.stringOrNull }.orEmpty() + return if (missing.isEmpty()) message else "$message (missing: ${missing.joinToString(", ")})" +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/sessions/SessionListScreen.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/sessions/SessionListScreen.kt new file mode 100644 index 0000000000..d2585fa265 --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/sessions/SessionListScreen.kt @@ -0,0 +1,644 @@ +package app.hapi.companion.feature.sessions + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import app.hapi.companion.R +import app.hapi.companion.ui.components.AgentFlavorIcon +import app.hapi.companion.ui.theme.HapiTheme +import app.hapi.protocol.wire.PendingRequest +import app.hapi.protocol.wire.SessionSummary +import app.hapi.protocol.wire.SessionSummaryMetadata +import app.hapi.protocol.wire.SummaryText +import app.hapi.protocol.wire.TodoProgress + +/** + * The session list (B-M2b) — standalone screen: navigation and app graph stay + * outside; taps surface through [onOpenSession]. + * + * Inventory (mirrors the web sidebar semantics): + * - offline banner over snapshot data, machine filter chips (≥ 2 machines), + * pull-to-refresh, empty state; + * - pinned section first (the sort already puts globalPinned/pinned rows on + * top; a header + divider make the boundary visible); + * - per row: flavor brand icon + title, spinner while a turn is in flight, + * summary line, `project · worktree · machine` meta line (machine only + * when it disambiguates), relative `updatedAt`, pending-request badge, + * todo-progress chip, unread dot; disconnected rows are dimmed — + * connected is the resting state, so no presence dot (web parity); + * - long-press → actions sheet: pin (none/project/global), rename (dialog), + * reopen (inactive rows; navigates into the possibly-superseding id), + * archive, delete (confirm; 409 while active) — optimistic store updates; + * failures land in a snackbar (B-M2b + B-M3ce). + * + * The host (`HomeScreen` via Navigation) constructs the ViewModel from the + * active `HubGraph` and routes [onOpenSession] to the chat screen. This + * screen starts/stops the ViewModel — and with it the global SSE + * subscription — with its composition. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SessionListScreen( + viewModel: SessionListViewModel, + onOpenSession: (sessionId: String) -> Unit, + modifier: Modifier = Modifier, + /** Shows the "+" FAB (new-session form, B-M3d) when non-null. */ + onNewSession: (() -> Unit)? = null, +) { + val state by viewModel.uiState.collectAsState() + val snackbarHostState = remember { SnackbarHostState() } + var sheetRow by remember { mutableStateOf(null) } + var renameRow by remember { mutableStateOf(null) } + var deleteRow by remember { mutableStateOf(null) } + + DisposableEffect(viewModel) { + viewModel.start() + onDispose { viewModel.stop() } + } + + val context = LocalContext.current + LaunchedEffect(viewModel, context) { + viewModel.errors.collect { error -> + val label = context.getString( + when (error) { + is SessionListError.PinFailed -> R.string.sessions_error_pin + is SessionListError.ArchiveFailed -> R.string.sessions_error_archive + is SessionListError.RenameFailed -> R.string.sessions_error_rename + is SessionListError.DeleteFailed -> R.string.sessions_error_delete + is SessionListError.ReopenFailed -> R.string.sessions_error_reopen + is SessionListError.MachinesRefreshFailed -> R.string.sessions_error_machines + }, + ) + // The 409-on-delete conflict gets explicit wording; other messages + // are hub/server text appended verbatim. + val message = when { + error is SessionListError.DeleteFailed && error.stillActive -> + context.getString(R.string.sessions_error_delete_active) + else -> error.message + } + snackbarHostState.showSnackbar(message?.let { "$label: $it" } ?: label) + } + } + + // Reopen may hand back a superseding id — open whatever the hub returned. + LaunchedEffect(viewModel) { + viewModel.reopened.collect { sessionId -> + viewModel.onSessionOpened(sessionId) + onOpenSession(sessionId) + } + } + + Box(modifier = modifier.fillMaxSize()) { + Column(modifier = Modifier.fillMaxSize()) { + if (state.isOffline) { + OfflineBanner() + } + if (state.showMachineFilterBar) { + MachineFilterRow( + filters = state.machineFilters, + activeFilter = state.activeMachineFilter, + onSelect = viewModel::setMachineFilter, + ) + } + PullToRefreshBox( + isRefreshing = state.isRefreshing, + onRefresh = viewModel::refresh, + modifier = Modifier + .fillMaxWidth() + .weight(1f), + ) { + if (state.rows.isEmpty()) { + EmptyState(hasLoaded = state.hasLoaded, isOffline = state.isOffline) + } else { + SessionRows( + rows = state.rows, + onOpen = { sessionId -> + viewModel.onSessionOpened(sessionId) + onOpenSession(sessionId) + }, + onLongPress = { sheetRow = it }, + ) + } + } + } + onNewSession?.let { openNewSession -> + FloatingActionButton( + onClick = openNewSession, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(16.dp), + ) { + Icon( + Icons.Default.Add, + contentDescription = stringResource(R.string.new_session_fab), + ) + } + } + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier.align(Alignment.BottomCenter), + ) + } + + sheetRow?.let { row -> + SessionActionsSheet( + row = row, + onDismiss = { sheetRow = null }, + onSetPinMode = { mode -> + viewModel.setPinMode(row.id, mode) + sheetRow = null + }, + onArchive = { + viewModel.archiveSession(row.id) + sheetRow = null + }, + onRename = { + renameRow = row + sheetRow = null + }, + onReopen = { + viewModel.reopenSession(row.id) + sheetRow = null + }, + onDelete = { + deleteRow = row + sheetRow = null + }, + ) + } + renameRow?.let { row -> + RenameSessionDialog( + initialName = row.summary.metadata?.name ?: row.title, + onConfirm = { name -> + viewModel.renameSession(row.id, name) + renameRow = null + }, + onDismiss = { renameRow = null }, + ) + } + deleteRow?.let { row -> + DeleteSessionDialog( + sessionTitle = row.title, + onConfirm = { + viewModel.deleteSession(row.id) + deleteRow = null + }, + onDismiss = { deleteRow = null }, + ) + } +} + +// ------------------------------------------------------------------ list -- + +@Composable +private fun SessionRows( + rows: List, + onOpen: (String) -> Unit, + onLongPress: (SessionRowUi) -> Unit, +) { + // The sort contract puts globalPinned/pinned rows first; the boundary + // index is where the pinned section ends. + val pinnedCount = rows.takeWhile { + it.summary.globalPinned == true || it.summary.pinned == true + }.size + + LazyColumn(modifier = Modifier.fillMaxSize()) { + if (pinnedCount > 0) { + item(key = "header-pinned") { SectionHeader(stringResource(R.string.sessions_section_pinned)) } + } + items(rows.take(pinnedCount), key = { it.id }) { row -> + SessionRow(row, onOpen = onOpen, onLongPress = onLongPress) + } + if (pinnedCount in 1 until rows.size) { + item(key = "divider-pinned") { + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + SectionHeader(stringResource(R.string.sessions_section_sessions)) + } + } + items(rows.drop(pinnedCount), key = { it.id }) { row -> + SessionRow(row, onOpen = onOpen, onLongPress = onLongPress) + } + } +} + +@Composable +private fun SectionHeader(text: String) { + Text( + text = text, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp), + ) +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun SessionRow( + row: SessionRowUi, + onOpen: (String) -> Unit, + onLongPress: (SessionRowUi) -> Unit, +) { + val summary = row.summary + Column( + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { onOpen(row.id) }, + onLongClick = { onLongPress(row) }, + ) + // 8dp keeps the old content-to-gap rhythm: rows shrank from three + // text lines to two, and the unchanged 10dp read as oversized + // gaps between the now-shorter items (device feedback). + .padding(horizontal = 16.dp, vertical = 8.dp) + // Dimming expresses "disconnected" (web parity): connected is the + // resting state here, so only the exception gets marked — no + // per-row presence dot. + .alpha(if (summary.active) 1f else 0.5f), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + AgentFlavorIcon(row.flavor, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(6.dp)) + // One weighted element only: the title takes ALL leftover + // width (start-aligned, ellipsis on true overflow). Splitting + // the slack with a weighted trailing spacer truncated even + // short names at ~half the row (device-observed). + Text( + text = row.title, + style = MaterialTheme.typography.bodyLarge, + fontWeight = if (row.unread) FontWeight.SemiBold else FontWeight.Normal, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + if (summary.active && summary.thinking) { + Spacer(modifier = Modifier.width(6.dp)) + CircularProgressIndicator( + modifier = Modifier.size(14.dp), + strokeWidth = 1.5.dp, + color = Color(0xFF34C759), + ) + } + if (row.unread) { + Spacer(modifier = Modifier.width(6.dp)) + UnreadDot() + } + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = localizedRelativeAge(summary.updatedAt), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + // Summary directly under the title (its prose continuation); the + // `project · machine` meta closes the row as a footer. + row.subtitle?.let { subtitle -> + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + MetaLine(row) + BadgeLine(summary) + } +} + +// `project · worktree · machine`, composed in the ViewModel (machine only +// when it disambiguates) — the row just renders it. +@Composable +private fun MetaLine(row: SessionRowUi) { + val meta = row.meta ?: return + Text( + text = meta, + // bodySmall, not labelSmall: as the row's only secondary line the + // meta carries the project scan key — label tracking (0.5sp at 11sp) + // reads stringy on path-like text (web parity: title 14 / meta 12). + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) +} + +@Composable +private fun BadgeLine(summary: SessionSummary) { + val hasPending = summary.pendingRequestsCount > 0 + val todoProgress = summary.todoProgress + if (!hasPending && todoProgress == null) return + Row( + modifier = Modifier.padding(top = 4.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (hasPending) { + PendingBadge( + count = summary.pendingRequestsCount, + kinds = summary.pendingRequestKinds, + requests = summary.pendingRequests, + ) + } + todoProgress?.let { TodoChip(it) } + } +} + +/** + * Pending badge: authoritative `pendingRequestsCount` + kind wording; the + * capped `pendingRequests` slice names the first tool. + */ +@Composable +private fun PendingBadge(count: Int, kinds: List, requests: List) { + val needsInput = kinds.contains("input") && !kinds.contains("permission") + val label = when { + needsInput -> stringResource(R.string.sessions_badge_needs_input) + requests.isNotEmpty() -> stringResource(R.string.sessions_badge_approve, requests.first().tool) + else -> stringResource(R.string.sessions_badge_pending) + } + val text = if (count > 1) "$count · $label" else label + Surface( + color = MaterialTheme.colorScheme.tertiaryContainer, + contentColor = MaterialTheme.colorScheme.onTertiaryContainer, + shape = RoundedCornerShape(6.dp), + ) { + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + ) + } +} + +@Composable +private fun TodoChip(progress: TodoProgress) { + Surface( + color = MaterialTheme.colorScheme.surfaceContainerHigh, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant, + shape = RoundedCornerShape(6.dp), + ) { + Text( + text = "☑ ${progress.completed}/${progress.total}", + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + ) + } +} + +@Composable +private fun UnreadDot() { + Box( + modifier = Modifier + .size(8.dp) + .background(MaterialTheme.colorScheme.primary, CircleShape), + ) +} + +// --------------------------------------------------------------- chrome -- + +@Composable +private fun MachineFilterRow( + filters: List, + activeFilter: String?, + onSelect: (String?) -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 12.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + FilterChip( + selected = activeFilter == null, + onClick = { onSelect(null) }, + label = { Text(stringResource(R.string.sessions_filter_all)) }, + ) + filters.forEach { filter -> + val label = filter.label.ifBlank { stringResource(R.string.sessions_filter_unknown_machine) } + FilterChip( + selected = activeFilter == filter.id, + onClick = { onSelect(if (activeFilter == filter.id) null else filter.id) }, + label = { Text("$label · ${filter.sessionCount}") }, + ) + } + } +} + +@Composable +private fun OfflineBanner() { + Surface( + color = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = stringResource(R.string.sessions_offline_banner), + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp), + ) + } +} + +@Composable +private fun EmptyState(hasLoaded: Boolean, isOffline: Boolean) { + // verticalScroll keeps the pull-to-refresh gesture available even though + // there is nothing to scroll. + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(32.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource( + when { + !hasLoaded && !isOffline -> R.string.sessions_empty_loading_title + isOffline -> R.string.sessions_empty_offline_title + else -> R.string.sessions_empty_title + }, + ), + style = MaterialTheme.typography.titleMedium, + ) + Spacer(modifier = Modifier.size(8.dp)) + Text( + text = stringResource( + when { + !hasLoaded && !isOffline -> R.string.sessions_empty_loading_hint + isOffline -> R.string.sessions_empty_offline_hint + else -> R.string.sessions_empty_hint + }, + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SessionActionsSheet( + row: SessionRowUi, + onDismiss: () -> Unit, + onSetPinMode: (PinMode) -> Unit, + onArchive: () -> Unit, + onRename: () -> Unit, + onReopen: () -> Unit, + onDelete: () -> Unit, +) { + val summary = row.summary + ModalBottomSheet(onDismissRequest = onDismiss) { + Text( + text = row.title, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + HorizontalDivider() + if (summary.pinned == true || summary.globalPinned == true) { + SheetAction(stringResource(R.string.sessions_action_unpin)) { onSetPinMode(PinMode.None) } + } + if (summary.pinned != true) { + SheetAction(stringResource(R.string.sessions_action_pin_project)) { onSetPinMode(PinMode.Project) } + } + if (summary.globalPinned != true) { + SheetAction(stringResource(R.string.sessions_action_pin_global)) { onSetPinMode(PinMode.Global) } + } + SheetAction(stringResource(R.string.sessions_action_rename), onClick = onRename) + if (!summary.active) { + SheetAction(stringResource(R.string.sessions_action_reopen), onClick = onReopen) + } + SheetAction(stringResource(R.string.sessions_action_archive), destructive = true, onClick = onArchive) + SheetAction(stringResource(R.string.sessions_action_delete), destructive = true, onClick = onDelete) + Spacer(modifier = Modifier.size(16.dp)) + } +} + +@Composable +private fun SheetAction(text: String, destructive: Boolean = false, onClick: () -> Unit) { + ListItem( + headlineContent = { + Text( + text = text, + color = if (destructive) MaterialTheme.colorScheme.error else Color.Unspecified, + ) + }, + modifier = Modifier.clickable(onClick = onClick), + ) +} + +// -------------------------------------------------------------- preview -- + +private fun previewRow( + id: String, + title: String, + active: Boolean = false, + thinking: Boolean = false, + unread: Boolean = false, + pinned: Boolean = false, + pending: Int = 0, + todo: TodoProgress? = null, +): SessionRowUi = SessionRowUi( + summary = SessionSummary( + id = id, + active = active, + thinking = thinking, + activeAt = 0, + updatedAt = System.currentTimeMillis() - 300_000, + pinned = pinned, + metadata = SessionSummaryMetadata( + path = "/data/github/hapi", + flavor = "claude", + summary = SummaryText("Porting the session list to Compose"), + ), + todoProgress = todo, + pendingRequestsCount = pending, + pendingRequestKinds = if (pending > 0) listOf("permission") else emptyList(), + pendingRequests = if (pending > 0) { + listOf(PendingRequest(id = "r1", kind = "permission", tool = "Bash", since = 0)) + } else { + emptyList() + }, + ), + title = title, + subtitle = "Porting the session list to Compose", + meta = "github/hapi · devbox", + flavor = "claude", + unread = unread, +) + +@Preview(showBackground = true) +@Composable +private fun SessionRowsPreview() { + HapiTheme { + Surface { + SessionRows( + rows = listOf( + previewRow("s1", "Pinned build fix", pinned = true), + previewRow("s2", "Session list UI", active = true, thinking = true, unread = true, todo = TodoProgress(3, 5)), + previewRow("s3", "Fixture sweep", active = true, pending = 2), + previewRow("s4", "Old research"), + ), + onOpen = {}, + onLongPress = {}, + ) + } + } +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/sessions/SessionListViewModel.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/sessions/SessionListViewModel.kt new file mode 100644 index 0000000000..83f3c0b6a1 --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/sessions/SessionListViewModel.kt @@ -0,0 +1,427 @@ +package app.hapi.companion.feature.sessions + +import app.hapi.data.api.ApiError +import app.hapi.data.store.LastSeenStore +import app.hapi.data.store.MachineListStore +import app.hapi.data.store.SessionListStore +import app.hapi.protocol.wire.Machine +import app.hapi.protocol.wire.SessionSummary +import kotlin.coroutines.cancellation.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +/** Sessions whose metadata carries no machine id group under this filter id. */ +const val UNKNOWN_MACHINE_ID: String = "__unknown__" + +/** One rendered list row: the summary plus everything derived for display. */ +data class SessionRowUi( + val summary: SessionSummary, + /** `getSessionTitle` port: name → summary text → path tail → id prefix. */ + val title: String, + /** Secondary line: summary text, only when it is not already the title. */ + val subtitle: String?, + /** + * Single meta line, `project · worktree · machine`: project is the last + * two segments of the worktree base path (session path fallback, the web + * sidebar's group-name rule); the machine label is disambiguation only — + * present only when several machines are known and no machine filter is + * active. Full paths never render in the list (title tooltip territory + * on web; here the session detail owns them). + */ + val meta: String?, + /** Raw flavor id (`claude`, `codex`, …); resolve labels via the catalog. */ + val flavor: String?, + val unread: Boolean, +) { + val id: String get() = summary.id +} + +data class MachineFilterUi( + /** Machine id or [UNKNOWN_MACHINE_ID]. */ + val id: String, + val label: String, + val sessionCount: Int, +) + +data class SessionListUiState( + val rows: List, + /** Render the chip bar only when at least two machines have sessions. */ + val machineFilters: List, + /** `null` = All. Always one of [machineFilters] ids (stale picks fall back). */ + val activeMachineFilter: String?, + val isRefreshing: Boolean, + /** True once either the snapshot or a refresh produced a list. */ + val hasLoaded: Boolean, + /** Last refresh failed — show the offline banner over snapshot data. */ + val isOffline: Boolean, +) { + val showMachineFilterBar: Boolean get() = machineFilters.size >= 2 +} + +/** + * Session-list state machine: combines [SessionListStore] / [MachineListStore] + * / [LastSeenStore] with the machine-filter selection into [uiState] and + * forwards pin/archive actions with store-side optimistic updates. + * + * The global SSE subscription is NOT owned here anymore (B-M3ab): `HubGraph` + * runs it for its whole lifetime via `GlobalSsePipe`, so queued/consumed + * bookkeeping and list badges stay fresh while a chat screen is open. + * + * Plain constructor — no Android dependency, so JVM tests drive it with fake + * stores. Navigation hosts it behind a per-hub lifecycle holder built from + * `HubGraph`; the screen calls [start]/[stop] with its composition. + */ +class SessionListViewModel( + private val sessionStore: SessionListStore, + private val machineStore: MachineListStore, + private val lastSeenStore: LastSeenStore, + private val scope: CoroutineScope, + /** Last-seen baseline scope, e.g. the hub origin. */ + private val hubKey: String = "default", +) { + private val machineFilter = MutableStateFlow(null) + private val isRefreshing = MutableStateFlow(false) + private val isOffline = MutableStateFlow(false) + private val hasRefreshedOnce = MutableStateFlow(false) + + private val _errors = MutableSharedFlow(extraBufferCapacity = 8) + + /** Transient action failures (pin/archive/rename/delete/reopen) for a snackbar. */ + val errors: SharedFlow = _errors.asSharedFlow() + + private val _reopened = MutableSharedFlow(extraBufferCapacity = 4) + + /** Reopen succeeded — navigate into this (possibly superseding) session id. */ + val reopened: SharedFlow = _reopened.asSharedFlow() + + private var refreshJob: Job? = null + + init { + // Live SSE data is proof of connectivity: any list emission after a + // failed refresh clears the stale offline banner (a device-observed + // contradiction — active sessions updating under an "offline" banner). + scope.launch { + sessionStore.sessions.drop(1).collect { sessions -> + if (isOffline.value) isOffline.value = false + // Seed the unread baseline from SSE data too — when REST + // refresh fails but the stream works, unseeded watermarks + // would light every row's unread dot (once-per-scope inside + // the store, so repeated calls are no-ops). + if (sessions.isNotEmpty()) { + runCatching { lastSeenStore.initializeBaseline(hubKey, sessions) } + } + } + } + } + + val uiState: StateFlow = combine( + sessionStore.sessions, + machineStore.machines, + lastSeenStore.state, + machineFilter, + combine(isRefreshing, isOffline, hasRefreshedOnce) { refreshing, offline, loaded -> + Triple(refreshing, offline, loaded) + }, + ) { sessions, machines, lastSeen, filter, (refreshing, offline, refreshedOnce) -> + buildUiState( + sessions = sessions, + machines = machines, + lastSeen = lastSeen.lastSeen, + filter = filter, + isRefreshing = refreshing, + isOffline = offline, + hasLoaded = refreshedOnce || sessions.isNotEmpty(), + ) + }.stateIn( + scope = scope, + started = SharingStarted.Eagerly, + initialValue = SessionListUiState( + rows = emptyList(), + machineFilters = emptyList(), + activeMachineFilter = null, + isRefreshing = false, + hasLoaded = sessionStore.sessions.value.isNotEmpty(), + isOffline = false, + ), + ) + + /** + * Screen entry. The global SSE pipe already runs at `HubGraph` scope; + * this only kicks the explicit entry refresh (the snapshot may be stale + * and a `resume: ok` handshake deliberately skips the REST resync). + * Safe to call repeatedly. + */ + fun start() { + refresh() + } + + /** Screen exit. The hub-lifetime global pipe stays up by design. */ + fun stop() { + refreshJob?.cancel() + } + + /** Pull-to-refresh / initial load. Coalesces concurrent calls. */ + fun refresh() { + if (refreshJob?.isActive == true) return + refreshJob = scope.launch { + isRefreshing.value = true + try { + // Only a failed *sessions* fetch means "offline". Machines and + // the unread baseline are secondary: their failures must not + // pin the offline banner over a perfectly live list (this + // exact cascade shipped once — a machines decode error kept + // the banner up while SSE streamed active sessions). + sessionStore.refresh() + isOffline.value = false + hasRefreshedOnce.value = true + // First successful list for this hub seeds the unread baseline + // so historical sessions do not all light up as unread. + runCatching { lastSeenStore.initializeBaseline(hubKey, sessionStore.sessions.value) } + try { + machineStore.refresh() + } catch (cancellation: CancellationException) { + throw cancellation + } catch (error: Exception) { + _errors.tryEmit(SessionListError.MachinesRefreshFailed(error.message)) + } + } catch (cancellation: CancellationException) { + throw cancellation + } catch (_: Exception) { + isOffline.value = true + } finally { + isRefreshing.value = false + } + } + } + + fun setMachineFilter(machineId: String?) { + machineFilter.value = machineId + } + + /** Call when navigating into a session: stamps the last-seen watermark. */ + fun onSessionOpened(sessionId: String) { + val summary = sessionStore.sessions.value.firstOrNull { it.id == sessionId } ?: return + lastSeenStore.markSeen(sessionId, summary.updatedAt) + } + + /** `PUT /sessions/:id/pin` with optimistic re-sort; failures surface on [errors]. */ + fun setPinMode(sessionId: String, mode: PinMode) { + scope.launch { + try { + sessionStore.setPinMode(sessionId, mode.wire) + } catch (cancellation: CancellationException) { + throw cancellation + } catch (error: Exception) { + _errors.tryEmit(SessionListError.PinFailed(sessionId, error.message)) + } + } + } + + /** `POST /sessions/:id/archive` with optimistic removal; failures surface on [errors]. */ + fun archiveSession(sessionId: String) { + scope.launch { + try { + sessionStore.archiveSession(sessionId) + } catch (cancellation: CancellationException) { + throw cancellation + } catch (error: Exception) { + _errors.tryEmit(SessionListError.ArchiveFailed(sessionId, error.message)) + } + } + } + + /** `PATCH /sessions/:id` rename with optimistic `metadata.name`; failures surface on [errors]. */ + fun renameSession(sessionId: String, name: String) { + val trimmed = name.trim() + if (trimmed.isEmpty()) return + scope.launch { + try { + sessionStore.renameSession(sessionId, trimmed) + } catch (cancellation: CancellationException) { + throw cancellation + } catch (error: Exception) { + _errors.tryEmit(SessionListError.RenameFailed(sessionId, error.message)) + } + } + } + + /** `DELETE /sessions/:id` with optimistic removal; 409 while active gets explicit wording (in the UI). */ + fun deleteSession(sessionId: String) { + scope.launch { + try { + sessionStore.deleteSession(sessionId) + } catch (cancellation: CancellationException) { + throw cancellation + } catch (error: Exception) { + val stillActive = error is ApiError && error.status == 409 + _errors.tryEmit( + SessionListError.DeleteFailed( + sessionId = sessionId, + message = if (stillActive) null else error.message, + stillActive = stillActive, + ), + ) + } + } + } + + /** + * `POST /sessions/:id/reopen` — success emits the (possibly superseding) + * id on [reopened] so the screen navigates into it; 422 missing-metadata + * and other failures surface on [errors] via [formatReopenError]. + */ + fun reopenSession(sessionId: String) { + scope.launch { + try { + _reopened.tryEmit(sessionStore.reopenSession(sessionId).sessionId) + } catch (cancellation: CancellationException) { + throw cancellation + } catch (error: Exception) { + _errors.tryEmit(SessionListError.ReopenFailed(sessionId, formatReopenError(error))) + } + } + } + + // ------------------------------------------------------------ mapping -- + + private fun buildUiState( + sessions: List, + machines: List, + lastSeen: Map, + filter: String?, + isRefreshing: Boolean, + isOffline: Boolean, + hasLoaded: Boolean, + ): SessionListUiState { + val machinesById = machines.associateBy { it.id } + + fun machineLabel(machineId: String?): String? { + if (machineId == null) return null + val metadata = machinesById[machineId]?.metadata ?: return machineId.take(8) + val displayName = metadata.displayName?.takeIf { it.isNotBlank() } + return displayName ?: metadata.host + } + + // Filter chips derive from ALL sessions (pre-filter), like the web — + // filtering first would drop chips and silently clear the selection. + val filters = sessions + .groupBy { it.metadata?.machineId ?: UNKNOWN_MACHINE_ID } + .map { (id, group) -> + MachineFilterUi( + id = id, + label = if (id == UNKNOWN_MACHINE_ID) "" else machineLabel(id).orEmpty(), + sessionCount = group.size, + ) + } + .sortedByDescending { it.sessionCount } + + // A persisted pick whose machine no longer has sessions falls back to + // All; with fewer than two machines the bar hides and never filters. + val activeFilter = filter + ?.takeIf { filters.size >= 2 && filters.any { chip -> chip.id == it } } + + val visible = if (activeFilter == null) { + sessions + } else { + sessions.filter { (it.metadata?.machineId ?: UNKNOWN_MACHINE_ID) == activeFilter } + } + + // With one machine — or a machine filter active — every visible row + // shares the machine, so repeating it per row is noise. + val showMachine = filters.size >= 2 && activeFilter == null + + val rows = visible.map { summary -> + val title = sessionTitle(summary) + val summaryText = summary.metadata?.summary?.text?.takeIf { it.isNotBlank() } + SessionRowUi( + summary = summary, + title = title, + subtitle = summaryText?.takeIf { it != title }, + meta = buildList { + projectLabel(summary)?.let(::add) + summary.metadata?.worktree?.let { add(it.name.ifBlank { it.branch }) } + if (showMachine) machineLabel(summary.metadata?.machineId)?.let(::add) + }.takeIf { it.isNotEmpty() }?.joinToString(" · "), + flavor = summary.metadata?.flavor, + unread = LastSeenStore.isUnread(summary, lastSeen[summary.id] ?: 0), + ) + } + + return SessionListUiState( + rows = rows, + machineFilters = filters, + activeMachineFilter = activeFilter, + isRefreshing = isRefreshing, + hasLoaded = hasLoaded, + isOffline = isOffline, + ) + } + + companion object { + /** `getSessionTitle` (`web/src/lib/sessionTitle.ts`). */ + fun sessionTitle(summary: SessionSummary): String { + val metadata = summary.metadata + metadata?.name?.takeIf { it.isNotEmpty() }?.let { return it } + metadata?.summary?.text?.takeIf { it.isNotEmpty() }?.let { return it } + metadata?.path?.let { path -> + val tail = path.split('/').lastOrNull { it.isNotEmpty() } + if (tail != null) return tail + } + return summary.id.take(8) + } + + /** + * Project identity for the meta line: last two segments of the + * worktree base path, session path fallback — mirrors the web + * sidebar's `getGroupDisplayName` rule (`SessionList.tsx`). + */ + fun projectLabel(summary: SessionSummary): String? { + val path = summary.metadata?.worktree?.basePath ?: summary.metadata?.path + if (path.isNullOrEmpty()) return null + val parts = path.split('/', '\\').filter { it.isNotEmpty() } + return when { + parts.isEmpty() -> path + parts.size == 1 -> parts[0] + else -> "${parts[parts.size - 2]}/${parts[parts.size - 1]}" + } + } + } +} + +/** `PUT /sessions/:id/pin` modes. */ +enum class PinMode(val wire: String) { + None("none"), + Project("project"), + Global("global"), +} + +sealed interface SessionListError { + val sessionId: String + val message: String? + + data class PinFailed(override val sessionId: String, override val message: String?) : SessionListError + data class ArchiveFailed(override val sessionId: String, override val message: String?) : SessionListError + data class RenameFailed(override val sessionId: String, override val message: String?) : SessionListError + data class DeleteFailed( + override val sessionId: String, + override val message: String?, + /** `DELETE` answered 409 — session still active; UI shows the archive-first wording. */ + val stillActive: Boolean = false, + ) : SessionListError + data class ReopenFailed(override val sessionId: String, override val message: String?) : SessionListError + + /** Machines list refresh failed — advisory only, never the offline banner. */ + data class MachinesRefreshFailed(override val message: String?) : SessionListError { + override val sessionId: String get() = "" + } +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/sessions/SessionOpsDialogs.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/sessions/SessionOpsDialogs.kt new file mode 100644 index 0000000000..47effda590 --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/sessions/SessionOpsDialogs.kt @@ -0,0 +1,97 @@ +package app.hapi.companion.feature.sessions + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import app.hapi.companion.R +import app.hapi.companion.ui.theme.HapiTheme + +/** + * Session-op confirmation dialogs (B-M3ce), shared by the session-list sheet + * and the chat top-bar overflow menu. + */ + +/** Rename prompt: prefilled text field, confirm disabled while blank (hub: 1–255 chars). */ +@Composable +fun RenameSessionDialog( + initialName: String, + onConfirm: (String) -> Unit, + onDismiss: () -> Unit, +) { + var name by remember { mutableStateOf(initialName) } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.sessions_rename_title)) }, + text = { + OutlinedTextField( + value = name, + onValueChange = { name = it.take(255) }, + singleLine = true, + placeholder = { Text(stringResource(R.string.sessions_rename_placeholder)) }, + ) + }, + confirmButton = { + TextButton( + enabled = name.isNotBlank(), + onClick = { onConfirm(name.trim()) }, + ) { Text(stringResource(R.string.sessions_action_rename)) } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.sessions_cancel)) } + }, + ) +} + +/** Delete confirmation — destructive and irreversible on the hub. */ +@Composable +fun DeleteSessionDialog( + sessionTitle: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.sessions_delete_title)) }, + text = { + Text(stringResource(R.string.sessions_delete_message, sessionTitle)) + }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text( + stringResource(R.string.sessions_action_delete), + color = MaterialTheme.colorScheme.error, + ) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.sessions_cancel)) } + }, + ) +} + +// -------------------------------------------------------------- previews -- + +@Preview +@Composable +private fun RenameSessionDialogPreview() { + HapiTheme { + RenameSessionDialog(initialName = "Fixture sweep", onConfirm = {}, onDismiss = {}) + } +} + +@Preview +@Composable +private fun DeleteSessionDialogPreview() { + HapiTheme { + DeleteSessionDialog(sessionTitle = "Fixture sweep", onConfirm = {}, onDismiss = {}) + } +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/settings/LanguagePrefs.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/settings/LanguagePrefs.kt new file mode 100644 index 0000000000..6a41f757b2 --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/settings/LanguagePrefs.kt @@ -0,0 +1,50 @@ +package app.hapi.companion.feature.settings + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.stringPreferencesKey +import java.io.IOException +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map + +/** + * App language choice. [ENGLISH]/[SIMPLIFIED_CHINESE] mirror the web's + * `Locale` (`'en' | 'zh-Hans'`); [SYSTEM] is the Android-only default — + * follow the device language (empty per-app locale list). + * + * B-M5a wires the selection through `AppCompatDelegate.setApplicationLocales` + * ([localeTags] is the BCP-47 tag list to apply); the Settings screen applies + * it immediately on selection and appcompat's `autoStoreLocales` re-applies it + * on cold start. + */ +enum class AppLanguage(val storageKey: String, val localeTags: String) { + SYSTEM("system", ""), + ENGLISH("en", "en"), + SIMPLIFIED_CHINESE("zh-Hans", "zh-Hans"); + + companion object { + fun fromStorageKey(raw: String?): AppLanguage = + entries.firstOrNull { it.storageKey == raw } ?: SYSTEM + } +} + +/** Language persistence over the app-wide `hapi_prefs` DataStore. */ +class LanguagePrefs(private val dataStore: DataStore) { + + val language: Flow = dataStore.data + .catch { error -> if (error is IOException) emit(emptyPreferences()) else throw error } + .map { prefs -> AppLanguage.fromStorageKey(prefs[LANGUAGE_KEY]) } + .distinctUntilChanged() + + suspend fun setLanguage(language: AppLanguage) { + dataStore.edit { it[LANGUAGE_KEY] = language.storageKey } + } + + companion object { + val LANGUAGE_KEY: Preferences.Key = stringPreferencesKey("app_language") + } +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/settings/SettingsPreviews.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/settings/SettingsPreviews.kt new file mode 100644 index 0000000000..566be975d6 --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/settings/SettingsPreviews.kt @@ -0,0 +1,143 @@ +package app.hapi.companion.feature.settings + +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import app.hapi.companion.ui.theme.HapiTheme +import app.hapi.protocol.wire.SqliteStorageUsageResponse +import app.hapi.protocol.wire.UsageSummaryBucket +import app.hapi.protocol.wire.UsageSummaryRange +import app.hapi.protocol.wire.UsageSummaryResponse +import app.hapi.protocol.wire.UsageSummaryTotals +import java.time.LocalDate + +/** + * Compile-checked previews doubling as visual regressions for the B-M4e + * dashboards: stat tiles, the daily bar chart, the ranked bar list, and the + * storage donut — light, dark, and OLED. + */ + +private fun bucket( + key: String, + input: Long, + output: Long, + cacheRead: Long = 0, + cacheCreation: Long = 0, + requests: Long = 10, +): UsageSummaryBucket = UsageSummaryBucket( + key = key, + inputTokens = input, + outputTokens = output, + cacheReadTokens = cacheRead, + cacheCreationTokens = cacheCreation, + totalTokens = input + output, + uncachedTokens = input - cacheRead, + requests = requests, +) + +internal val SAMPLE_USAGE_SUMMARY: UsageSummaryResponse = UsageSummaryResponse( + range = UsageSummaryRange(from = 1755000000000, to = 1755600000000), + totals = UsageSummaryTotals( + inputTokens = 12_400_000, + outputTokens = 310_000, + cacheReadTokens = 9_800_000, + cacheCreationTokens = 1_100_000, + totalTokens = 12_710_000, + uncachedTokens = 2_600_000, + requests = 1_842, + sessions = 23, + ), + daily = listOf( + bucket("2026-08-12", 1_200_000, 40_000, requests = 210), + bucket("2026-08-13", 3_400_000, 90_000, requests = 480), + bucket("2026-08-15", 900_000, 25_000, requests = 130), + bucket("2026-08-16", 5_100_000, 120_000, requests = 720), + bucket("2026-08-18", 1_800_000, 35_000, requests = 302), + ), + byAgent = listOf( + bucket("claude", 8_600_000, 220_000, requests = 1_300), + bucket("codex", 3_100_000, 70_000, requests = 420), + bucket("gemini", 700_000, 20_000, requests = 122), + ), + byModel = listOf( + bucket("claude-sonnet-4-5", 7_900_000, 190_000, requests = 1_150), + bucket("gpt-5.1-codex", 3_100_000, 70_000, requests = 420), + bucket("unknown", 1_400_000, 50_000, requests = 272), + ), + updatedAt = 1755600000000, +) + +internal val SAMPLE_STORAGE_USAGE: SqliteStorageUsageResponse = SqliteStorageUsageResponse( + path = "/home/hapi/.hapi/hapi.db", + databaseBytes = 84_930_560, + walBytes = 4_194_304, + shmBytes = 32_768, + totalBytes = 89_157_632, +) + +private val SAMPLE_DAILY_BARS: List = + UsageMath.dailyBars(SAMPLE_USAGE_SUMMARY.daily, days = 7, today = LocalDate.of(2026, 8, 18)) + +@Preview(name = "Usage tiles", showBackground = true) +@Composable +private fun UsageStatTilesPreview() { + HapiTheme(darkTheme = false, dynamicColor = false) { + Surface { UsageStatTiles(summary = SAMPLE_USAGE_SUMMARY) } + } +} + +@Preview(name = "Daily bars", showBackground = true) +@Composable +private fun DailyBarChartPreview() { + HapiTheme(darkTheme = false, dynamicColor = false) { + Surface { DailyBarChart(bars = SAMPLE_DAILY_BARS, modifier = Modifier.padding(12.dp)) } + } +} + +@Preview(name = "Daily bars · dark", showBackground = true, backgroundColor = 0xFF1B1B1F) +@Composable +private fun DailyBarChartDarkPreview() { + HapiTheme(darkTheme = true, dynamicColor = false) { + Surface { DailyBarChart(bars = SAMPLE_DAILY_BARS, modifier = Modifier.padding(12.dp)) } + } +} + +@Preview(name = "Bar list", showBackground = true) +@Composable +private fun UsageBarListPreview() { + HapiTheme(darkTheme = false, dynamicColor = false) { + Surface { UsageBarList(rows = SAMPLE_USAGE_SUMMARY.byAgent) } + } +} + +@Preview(name = "Usage · full body", showBackground = true, heightDp = 1400) +@Composable +private fun UsageSummaryContentPreview() { + HapiTheme(darkTheme = false, dynamicColor = false) { + Surface { + UsageSummaryContent( + summary = SAMPLE_USAGE_SUMMARY, + dailyBars = SAMPLE_DAILY_BARS, + ) + } + } +} + +@Preview(name = "Storage donut", showBackground = true) +@Composable +private fun StorageCardPreview() { + HapiTheme(darkTheme = false, dynamicColor = false) { + Surface { StorageUsageCard(usage = SAMPLE_STORAGE_USAGE) } + } +} + +@Preview(name = "Storage donut · OLED", showBackground = true, backgroundColor = 0xFF000000) +@Composable +private fun StorageCardOledPreview() { + HapiTheme(oled = true) { + Surface { StorageUsageCard(usage = SAMPLE_STORAGE_USAGE) } + } +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/settings/SettingsScreen.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/settings/SettingsScreen.kt new file mode 100644 index 0000000000..3ed106854c --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/settings/SettingsScreen.kt @@ -0,0 +1,378 @@ +package app.hapi.companion.feature.settings + +import android.os.Build +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.appcompat.app.AppCompatDelegate +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.core.os.LocaleListCompat +import app.hapi.companion.BuildConfig +import app.hapi.companion.R +import app.hapi.protocol.wire.SUPPORTED_PROTOCOL_VERSION + +/** + * Settings home (B-M4e scaffold): Appearance (theme mode + Material You), + * Language (persist-only until M5), the owner-only Usage/Storage entries, and + * About (app/protocol versions + hub health). + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen( + viewModel: SettingsViewModel, + onOpenUsage: () -> Unit, + onOpenStorage: () -> Unit, + onBack: () -> Unit, +) { + val theme by viewModel.themeSettings.collectAsState() + val language by viewModel.language.collectAsState() + val isOwner by viewModel.isOwner.collectAsState() + val hubInfo by viewModel.hubInfo.collectAsState() + + var showThemeDialog by rememberSaveable { mutableStateOf(false) } + var showLanguageDialog by rememberSaveable { mutableStateOf(false) } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.settings_title)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.settings_back), + ) + } + }, + ) + }, + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + SettingsSection(title = stringResource(R.string.settings_section_appearance)) { + SettingsRow( + label = stringResource(R.string.settings_theme), + value = stringResource(themeModeLabelRes(theme.mode)), + onClick = { showThemeDialog = true }, + ) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + SettingsDivider() + SwitchRow( + label = stringResource(R.string.settings_dynamic_color), + description = stringResource( + if (theme.mode == ThemeMode.OLED) R.string.settings_dynamic_color_oled_note + else R.string.settings_dynamic_color_description + ), + checked = theme.dynamicColor && theme.mode != ThemeMode.OLED, + enabled = theme.mode != ThemeMode.OLED, + onCheckedChange = viewModel::setDynamicColor, + ) + } + } + + SettingsSection(title = stringResource(R.string.settings_section_language)) { + SettingsRow( + label = stringResource(R.string.settings_language), + value = languageLabel(language), + onClick = { showLanguageDialog = true }, + ) + } + + if (isOwner) { + SettingsSection(title = stringResource(R.string.settings_section_insights)) { + SettingsRow( + label = stringResource(R.string.settings_usage), + description = stringResource(R.string.settings_usage_summary), + onClick = onOpenUsage, + ) + SettingsDivider() + SettingsRow( + label = stringResource(R.string.settings_storage), + description = stringResource(R.string.settings_storage_summary), + onClick = onOpenStorage, + ) + } + } + + SettingsSection(title = stringResource(R.string.settings_section_about)) { + SettingsRow( + label = stringResource(R.string.settings_app_version), + value = BuildConfig.VERSION_NAME, + ) + SettingsDivider() + SettingsRow( + label = stringResource(R.string.settings_protocol_version), + value = SUPPORTED_PROTOCOL_VERSION.toString(), + ) + SettingsDivider() + SettingsRow( + label = stringResource(R.string.settings_hub), + value = viewModel.hubUrl, + description = when (val info = hubInfo) { + is HubInfoState.Loading -> stringResource(R.string.settings_hub_checking) + is HubInfoState.Loaded -> stringResource( + R.string.settings_hub_health, + info.health.status, + info.health.protocolVersion, + ) + is HubInfoState.Failed -> stringResource(R.string.settings_hub_unreachable) + }, + onClick = if (hubInfo is HubInfoState.Failed) viewModel::retryHubInfo else null, + ) + } + } + } + + if (showThemeDialog) { + ThemeModeDialog( + selected = theme.mode, + onSelect = { mode -> + showThemeDialog = false + viewModel.setThemeMode(mode) + }, + onDismiss = { showThemeDialog = false }, + ) + } + + if (showLanguageDialog) { + LanguageDialog( + selected = language, + onSelect = { choice -> + showLanguageDialog = false + viewModel.setLanguage(choice) + // Apply immediately (B-M5a): appcompat recreates the activity + // with the new locale and, thanks to autoStoreLocales in the + // manifest, re-applies it on every cold start. The DataStore + // write above keeps the settings row in sync. + AppCompatDelegate.setApplicationLocales( + LocaleListCompat.forLanguageTags(choice.localeTags), + ) + }, + onDismiss = { showLanguageDialog = false }, + ) + } +} + +// ------------------------------------------------------------- primitives -- + +@Composable +private fun SettingsSection(title: String, content: @Composable () -> Unit) { + Column { + Text( + text = title, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 4.dp, bottom = 6.dp), + ) + Surface( + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = Modifier.fillMaxWidth(), + ) { + Column { content() } + } + } +} + +@Composable +private fun SettingsDivider() { + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant, + modifier = Modifier.padding(horizontal = 16.dp), + ) +} + +@Composable +private fun SettingsRow( + label: String, + value: String? = null, + description: String? = null, + onClick: (() -> Unit)? = null, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .let { if (onClick != null) it.clickable(onClick = onClick) else it } + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text(text = label, style = MaterialTheme.typography.bodyLarge) + if (description != null) { + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 2.dp), + ) + } + } + if (value != null) { + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(start = 12.dp), + ) + } + } +} + +@Composable +private fun SwitchRow( + label: String, + description: String, + checked: Boolean, + enabled: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text(text = label, style = MaterialTheme.typography.bodyLarge) + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 2.dp), + ) + } + Spacer(modifier = Modifier.padding(start = 12.dp)) + Switch(checked = checked, onCheckedChange = onCheckedChange, enabled = enabled) + } +} + +// ---------------------------------------------------------------- dialogs -- + +@Composable +private fun ThemeModeDialog( + selected: ThemeMode, + onSelect: (ThemeMode) -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.settings_theme)) }, + text = { + Column { + ThemeMode.entries.forEach { mode -> + RadioRow( + label = stringResource(themeModeLabelRes(mode)), + selected = mode == selected, + onClick = { onSelect(mode) }, + ) + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.settings_cancel)) } + }, + ) +} + +@Composable +private fun LanguageDialog( + selected: AppLanguage, + onSelect: (AppLanguage) -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.settings_language)) }, + text = { + Column { + AppLanguage.entries.forEach { language -> + RadioRow( + label = languageLabel(language), + selected = language == selected, + onClick = { onSelect(language) }, + ) + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.settings_cancel)) } + }, + ) +} + +@Composable +private fun RadioRow(label: String, selected: Boolean, onClick: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton(selected = selected, onClick = onClick) + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(start = 4.dp), + ) + } +} + +private fun themeModeLabelRes(mode: ThemeMode): Int = when (mode) { + ThemeMode.SYSTEM -> R.string.settings_theme_system + ThemeMode.LIGHT -> R.string.settings_theme_light + ThemeMode.DARK -> R.string.settings_theme_dark + ThemeMode.OLED -> R.string.settings_theme_oled +} + +/** + * Language names are shown in their own language (standard picker + * convention), so they are string literals, not resources; only the + * follow-system row translates with the app language. + */ +@Composable +private fun languageLabel(language: AppLanguage): String = when (language) { + AppLanguage.SYSTEM -> stringResource(R.string.settings_language_system) + AppLanguage.ENGLISH -> "English" + AppLanguage.SIMPLIFIED_CHINESE -> "简体中文" +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/settings/SettingsViewModel.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/settings/SettingsViewModel.kt new file mode 100644 index 0000000000..10555b9789 --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/settings/SettingsViewModel.kt @@ -0,0 +1,95 @@ +package app.hapi.companion.feature.settings + +import app.hapi.data.auth.JwtPeek +import app.hapi.protocol.wire.HubHealthResponse +import kotlin.coroutines.cancellation.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +/** Namespace whose JWT belongs to the hub owner (usage/storage visible). */ +const val OWNER_NAMESPACE: String = "default" + +/** + * Owner gate for the usage/storage entries: peeks the (unverified) `ns` claim + * — the web twin is `getNamespaceFromToken(token) === 'default'` + * (`SettingsNav.tsx`). Fails closed: no JWT / undecodable / missing claim all + * hide the owner-only rows; the endpoints' own 403 stays the real enforcement. + */ +fun isOwnerNamespace(jwt: String?): Boolean = + jwt != null && JwtPeek.peek(jwt)?.ns == OWNER_NAMESPACE + +/** About-section hub probe (`GET /health`). */ +sealed interface HubInfoState { + data object Loading : HubInfoState + data class Loaded(val health: HubHealthResponse) : HubInfoState + data class Failed(val message: String?) : HubInfoState +} + +/** + * Settings-home state: appearance + language prefs (DataStore-backed), the + * owner gate, and the About hub probe. Plain constructor for JVM tests; + * Navigation hosts it behind a per-hub lifecycle holder. + */ +class SettingsViewModel( + private val themePrefs: ThemePrefs, + private val languagePrefs: LanguagePrefs, + /** Active hub origin, shown in About. */ + val hubUrl: String, + /** Current JWT for the active hub (blocking-safe: called on a worker). */ + private val currentJwt: suspend () -> String?, + private val fetchHealth: suspend () -> HubHealthResponse, + private val scope: CoroutineScope, +) { + + val themeSettings: StateFlow = themePrefs.settings + .stateIn(scope, SharingStarted.Eagerly, ThemeSettings()) + + val language: StateFlow = languagePrefs.language + .stateIn(scope, SharingStarted.Eagerly, AppLanguage.SYSTEM) + + private val mutableIsOwner = MutableStateFlow(false) + + /** True when the active hub's JWT namespace is [OWNER_NAMESPACE]. */ + val isOwner: StateFlow = mutableIsOwner.asStateFlow() + + private val mutableHubInfo = MutableStateFlow(HubInfoState.Loading) + + val hubInfo: StateFlow = mutableHubInfo.asStateFlow() + + init { + scope.launch { mutableIsOwner.value = isOwnerNamespace(runCatching { currentJwt() }.getOrNull()) } + scope.launch { loadHubInfo() } + } + + fun setThemeMode(mode: ThemeMode) { + scope.launch { themePrefs.setMode(mode) } + } + + fun setDynamicColor(enabled: Boolean) { + scope.launch { themePrefs.setDynamicColor(enabled) } + } + + fun setLanguage(language: AppLanguage) { + scope.launch { languagePrefs.setLanguage(language) } + } + + fun retryHubInfo() { + mutableHubInfo.value = HubInfoState.Loading + scope.launch { loadHubInfo() } + } + + private suspend fun loadHubInfo() { + mutableHubInfo.value = try { + HubInfoState.Loaded(fetchHealth()) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + HubInfoState.Failed(e.message) + } + } +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/settings/StorageMath.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/settings/StorageMath.kt new file mode 100644 index 0000000000..0d88cc531b --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/settings/StorageMath.kt @@ -0,0 +1,75 @@ +package app.hapi.companion.feature.settings + +import app.hapi.protocol.wire.SqliteStorageUsageResponse + +/** + * Donut geometry for the sqlite storage chart — the Kotlin twin of + * `web/src/components/settings/storageUsageSlices.ts` (same fixed slice + * order, zero-byte filtering, 12-o'clock start, one-decimal percents). + */ +object StorageMath { + + enum class SliceKey { DATABASE, WAL, SHM } + + data class Slice( + val key: SliceKey, + val bytes: Long, + /** Share of the drawn total, one decimal (e.g. `97.3`). */ + val percent: Double, + /** Degrees, `-90` = 12 o'clock, clockwise (Android sweep convention). */ + val startAngle: Float, + val endAngle: Float, + ) + + /** Fixed entity order — colors follow the entity, never its rank. */ + private val SLICE_ORDER = listOf(SliceKey.DATABASE, SliceKey.WAL, SliceKey.SHM) + + private const val FULL_CIRCLE = 360f + + /** Start at 12 o'clock so the first slice reads top-heavy on mobile. */ + const val START_ANGLE = -90f + + /** + * Slices for the donut: zero-byte files are dropped, angles partition the + * full circle exactly (the last slice absorbs rounding), empty when + * nothing is drawn. + */ + fun slices(usage: SqliteStorageUsageResponse): List { + val entries = SLICE_ORDER + .map { key -> key to bytesFor(usage, key).coerceAtLeast(0) } + .filter { (_, bytes) -> bytes > 0 } + val total = entries.sumOf { (_, bytes) -> bytes } + if (total <= 0) return emptyList() + + var cursor = START_ANGLE + return entries.mapIndexed { index, (key, bytes) -> + val isLast = index == entries.lastIndex + val endAngle = if (isLast) { + START_ANGLE + FULL_CIRCLE + } else { + cursor + (bytes.toFloat() / total) * FULL_CIRCLE + } + Slice( + key = key, + bytes = bytes, + // Math.round: half-up like JS (kotlin.math.round is half-even). + percent = Math.round(bytes * 1000.0 / total) / 10.0, + startAngle = cursor, + endAngle = endAngle, + ).also { cursor = endAngle } + } + } + + /** `97.25` → `"97.3%"` (web `formatStoragePercent`, half-up). */ + fun formatPercent(percent: Double): String { + val rounded = Math.round(percent * 10) / 10.0 + val text = if (rounded == Math.floor(rounded)) rounded.toInt().toString() else rounded.toString() + return "$text%" + } + + private fun bytesFor(usage: SqliteStorageUsageResponse, key: SliceKey): Long = when (key) { + SliceKey.DATABASE -> usage.databaseBytes + SliceKey.WAL -> usage.walBytes + SliceKey.SHM -> usage.shmBytes + } +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/settings/StorageScreen.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/settings/StorageScreen.kt new file mode 100644 index 0000000000..db1fa5bf03 --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/settings/StorageScreen.kt @@ -0,0 +1,316 @@ +package app.hapi.companion.feature.settings + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import app.hapi.companion.R +import app.hapi.protocol.wire.SqliteStorageUsageResponse + +/** + * Owner-only sqlite storage dashboard (web + * `web/src/routes/settings/storage.tsx` + `StorageUsagePie` twin): a Canvas + * donut of db/wal/shm plus legend rows with byte formatting, total and path, + * and an explicit refresh. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun StorageScreen( + viewModel: StorageViewModel, + onBack: () -> Unit, +) { + LaunchedEffect(viewModel) { viewModel.start() } + val state by viewModel.state.collectAsState() + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.settings_storage)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.settings_back), + ) + } + }, + ) + }, + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = stringResource(R.string.settings_storage_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + when (val current = state) { + is StorageUiState.Loading -> Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 48.dp), + contentAlignment = Alignment.Center, + ) { CircularProgressIndicator() } + + is StorageUiState.Error -> DashboardError( + isForbidden = current.isForbidden, + message = current.message, + onRetry = viewModel::refresh, + ) + + is StorageUiState.Data -> { + StorageUsageCard(usage = current.usage) + Button( + onClick = viewModel::refresh, + enabled = !current.isRefreshing, + modifier = Modifier.align(Alignment.End), + ) { + Text( + stringResource( + if (current.isRefreshing) R.string.settings_storage_refreshing + else R.string.settings_storage_refresh + ), + ) + } + } + } + } + } +} + +/** Shared owner-gate/error body for both dashboards (usage + storage). */ +@Composable +internal fun DashboardError( + isForbidden: Boolean, + message: String?, + onRetry: () -> Unit, +) { + Surface( + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = Modifier.fillMaxWidth(), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringResource( + if (isForbidden) R.string.settings_owner_only else R.string.settings_load_error + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + if (!isForbidden && !message.isNullOrBlank()) { + Text( + text = message, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (!isForbidden) { + Button(onClick = onRetry) { Text(stringResource(R.string.settings_retry)) } + } + } + } +} + +/** Donut + legend + total/path rows for one sqlite usage snapshot. */ +@Composable +internal fun StorageUsageCard(usage: SqliteStorageUsageResponse, modifier: Modifier = Modifier) { + val slices = StorageMath.slices(usage) + Surface( + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + text = stringResource(R.string.settings_storage_chart_title), + style = MaterialTheme.typography.titleSmall, + ) + if (slices.isEmpty()) { + Text( + text = stringResource(R.string.settings_storage_chart_empty), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + StorageDonut(slices = slices, modifier = Modifier.size(180.dp)) + Column(horizontalAlignment = Alignment.CenterHorizontally) { + // Hero number: the one figure the chart exists for. + Text( + text = UsageMath.formatBytes(usage.totalBytes), + style = MaterialTheme.typography.titleLarge, + ) + Text( + text = stringResource(R.string.settings_storage_total), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Column { + slices.forEach { slice -> StorageLegendRow(slice) } + } + } + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + LabeledValueRow( + label = stringResource(R.string.settings_storage_total), + value = UsageMath.formatBytes(usage.totalBytes), + ) + Column { + Text( + text = stringResource(R.string.settings_storage_path), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = usage.path, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + ) + } + } + } +} + +@Composable +private fun StorageLegendRow(slice: StorageMath.Slice) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(10.dp) + .background(sliceColor(slice.key), RoundedCornerShape(3.dp)), + ) + Text( + text = stringResource(sliceLabelRes(slice.key)), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(start = 10.dp), + ) + Spacer(modifier = Modifier.weight(1f)) + Text( + text = "${UsageMath.formatBytes(slice.bytes)} · ${StorageMath.formatPercent(slice.percent)}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun LabeledValueRow(label: String, value: String) { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.weight(1f)) + Text(text = value, style = MaterialTheme.typography.bodyMedium) + } +} + +/** + * Slice colors mirror the web formula (link / 55% link–hint mix / hint) in + * Material tokens, so they track dynamic color and every theme mode. Fixed + * per entity — a missing wal/shm file never repaints its neighbors — and the + * legend rows carry identity, so color is never the only channel. + */ +@Composable +private fun sliceColor(key: StorageMath.SliceKey): Color = when (key) { + StorageMath.SliceKey.DATABASE -> MaterialTheme.colorScheme.primary + StorageMath.SliceKey.WAL -> lerp( + MaterialTheme.colorScheme.primary, + MaterialTheme.colorScheme.onSurfaceVariant, + 0.45f, + ) + StorageMath.SliceKey.SHM -> MaterialTheme.colorScheme.onSurfaceVariant +} + +private fun sliceLabelRes(key: StorageMath.SliceKey): Int = when (key) { + StorageMath.SliceKey.DATABASE -> R.string.settings_storage_database + StorageMath.SliceKey.WAL -> R.string.settings_storage_wal + StorageMath.SliceKey.SHM -> R.string.settings_storage_shm +} + +/** + * The donut ring on a plain [Canvas]: stroke arcs per slice with a small + * angular gap standing in for the 2px surface spacer whenever more than one + * slice is drawn. Angles come precomputed from [StorageMath.slices]. + */ +@Composable +internal fun StorageDonut(slices: List, modifier: Modifier = Modifier) { + val colors = slices.map { sliceColor(it.key) } + Canvas(modifier = modifier) { + val thickness = 26.dp.toPx() + val inset = thickness / 2 + val arcSize = Size(size.width - thickness, size.height - thickness) + val gapDegrees = if (slices.size > 1) 2f else 0f + slices.forEachIndexed { index, slice -> + val rawSweep = slice.endAngle - slice.startAngle + // Keep hairline slices visible: drop the gap before dropping the arc. + val gapped = rawSweep - gapDegrees > MIN_SWEEP_DEGREES + val sweep = if (gapped) rawSweep - gapDegrees else rawSweep.coerceAtLeast(MIN_SWEEP_DEGREES) + drawArc( + color = colors[index], + startAngle = if (gapped) slice.startAngle + gapDegrees / 2 else slice.startAngle, + sweepAngle = sweep, + useCenter = false, + topLeft = Offset(inset, inset), + size = arcSize, + style = Stroke(width = thickness), + ) + } + } +} + +private const val MIN_SWEEP_DEGREES = 0.5f diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/settings/StorageViewModel.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/settings/StorageViewModel.kt new file mode 100644 index 0000000000..2da60701ee --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/settings/StorageViewModel.kt @@ -0,0 +1,69 @@ +package app.hapi.companion.feature.settings + +import app.hapi.data.api.ApiError +import app.hapi.protocol.wire.SqliteStorageUsageResponse +import kotlin.coroutines.cancellation.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +/** Transport seam (`HapiApi.getSqliteStorageUsage`) for JVM tests. */ +fun interface StorageGateway { + suspend fun sqliteUsage(): SqliteStorageUsageResponse +} + +sealed interface StorageUiState { + data object Loading : StorageUiState + + data class Error( + val message: String?, + /** 403: non-owner namespace (mirror of the usage screen). */ + val isForbidden: Boolean, + ) : StorageUiState + + data class Data( + val usage: SqliteStorageUsageResponse, + val isRefreshing: Boolean = false, + ) : StorageUiState +} + +/** Storage-dashboard state: initial load + explicit refresh (web parity). */ +class StorageViewModel( + private val gateway: StorageGateway, + private val scope: CoroutineScope, +) { + + private val mutableState = MutableStateFlow(StorageUiState.Loading) + + val state: StateFlow = mutableState.asStateFlow() + + private var loadJob: Job? = null + + fun start() { + if (loadJob == null) refresh() + } + + fun refresh() { + if ((mutableState.value as? StorageUiState.Data)?.isRefreshing == true) return + loadJob?.cancel() + mutableState.value = when (val current = mutableState.value) { + is StorageUiState.Data -> current.copy(isRefreshing = true) + else -> StorageUiState.Loading + } + loadJob = scope.launch { + mutableState.value = try { + StorageUiState.Data(gateway.sqliteUsage()) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + StorageUiState.Error( + message = e.message, + isForbidden = (e as? ApiError)?.status == 403, + ) + } + } + } +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/settings/ThemePrefs.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/settings/ThemePrefs.kt new file mode 100644 index 0000000000..1f8cbbf14f --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/settings/ThemePrefs.kt @@ -0,0 +1,70 @@ +package app.hapi.companion.feature.settings + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.stringPreferencesKey +import java.io.IOException +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map + +/** + * Theme choice (web `ThemeMode` twin plus the Android-only Material You + * switch). [OLED] is the pure-black variant — it implies dark and disables + * dynamic color at the [app.hapi.companion.ui.theme.HapiTheme] call site. + */ +enum class ThemeMode(val storageKey: String) { + SYSTEM("system"), + LIGHT("light"), + DARK("dark"), + OLED("oled"); + + companion object { + /** Unknown/corrupt stored values degrade to [SYSTEM]. */ + fun fromStorageKey(raw: String?): ThemeMode = + entries.firstOrNull { it.storageKey == raw } ?: SYSTEM + } +} + +/** The persisted appearance choice, defaults = follow system + Material You. */ +data class ThemeSettings( + val mode: ThemeMode = ThemeMode.SYSTEM, + /** Material You wallpaper color (effective only on API 31+, non-OLED). */ + val dynamicColor: Boolean = true, +) + +/** + * Appearance persistence over the app-wide `hapi_prefs` DataStore. Read at + * `MainActivity.setContent` to drive `HapiTheme`, written by the settings + * screen; unreadable prefs degrade to defaults (roster storage precedent). + */ +class ThemePrefs(private val dataStore: DataStore) { + + /** Current settings; emits again on every change. */ + val settings: Flow = dataStore.data + .catch { error -> if (error is IOException) emit(emptyPreferences()) else throw error } + .map { prefs -> + ThemeSettings( + mode = ThemeMode.fromStorageKey(prefs[MODE_KEY]), + dynamicColor = prefs[DYNAMIC_COLOR_KEY] ?: true, + ) + } + .distinctUntilChanged() + + suspend fun setMode(mode: ThemeMode) { + dataStore.edit { it[MODE_KEY] = mode.storageKey } + } + + suspend fun setDynamicColor(enabled: Boolean) { + dataStore.edit { it[DYNAMIC_COLOR_KEY] = enabled } + } + + companion object { + val MODE_KEY: Preferences.Key = stringPreferencesKey("theme_mode") + val DYNAMIC_COLOR_KEY: Preferences.Key = booleanPreferencesKey("theme_dynamic_color") + } +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/settings/UsageBarChart.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/settings/UsageBarChart.kt new file mode 100644 index 0000000000..173ad6d309 --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/settings/UsageBarChart.kt @@ -0,0 +1,230 @@ +package app.hapi.companion.feature.settings + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.RoundRect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextMeasurer +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.drawText +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import app.hapi.companion.R +import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToInt + +/** + * Daily token bars on a plain [Canvas] (no chart library by design — plan + * track B). Single series, one hue; rounded data-ends anchored to a hairline + * baseline; recessive max/mid gridlines; first/mid/last day labels. Tapping a + * bar selects it and floats a tooltip with the day's numbers; tapping it + * again (or the chart padding) clears the selection. + */ +@Composable +fun DailyBarChart( + bars: List, + modifier: Modifier = Modifier, + chartHeight: Dp = 168.dp, +) { + var selectedIndex by remember(bars) { mutableStateOf(null) } + var plotSize by remember { mutableStateOf(IntSize.Zero) } + var tooltipSize by remember { mutableStateOf(IntSize.Zero) } + + val barColor = MaterialTheme.colorScheme.primary + val gridColor = MaterialTheme.colorScheme.outlineVariant + val labelColor = MaterialTheme.colorScheme.onSurfaceVariant + val labelStyle = TextStyle(color = labelColor, fontSize = 10.sp) + val textMeasurer = rememberTextMeasurer() + + val maxTokens = max(bars.maxOfOrNull { it.totalTokens } ?: 0L, 1L) + val density = LocalDensity.current + val labelRowPx = with(density) { LABEL_ROW_HEIGHT.toPx() } + + Box(modifier = modifier.fillMaxWidth()) { + Canvas( + modifier = Modifier + .fillMaxWidth() + .height(chartHeight) + .onSizeChanged { plotSize = it } + .pointerInput(bars) { + detectTapGestures { offset -> + val index = UsageMath.barIndexAt(offset.x, size.width.toFloat(), bars.size) + selectedIndex = if (index == selectedIndex) null else index + } + }, + ) { + drawDailyBars( + bars = bars, + maxTokens = maxTokens, + selectedIndex = selectedIndex, + plotBottomInset = labelRowPx, + barColor = barColor, + gridColor = gridColor, + labelStyle = labelStyle, + textMeasurer = textMeasurer, + ) + } + + val index = selectedIndex + if (index != null && index < bars.size && plotSize.width > 0) { + val slot = plotSize.width.toFloat() / bars.size + val centerX = slot * (index + 0.5f) + val offsetX = (centerX - tooltipSize.width / 2f) + .coerceIn(0f, max(0f, (plotSize.width - tooltipSize.width).toFloat())) + DayTooltip( + bar = bars[index], + modifier = Modifier + .onSizeChanged { tooltipSize = it } + .offset { IntOffset(offsetX.roundToInt(), 0) }, + ) + } + } +} + +@Composable +private fun DayTooltip(bar: UsageMath.DailyBar, modifier: Modifier = Modifier) { + Surface( + modifier = modifier, + shape = MaterialTheme.shapes.small, + color = MaterialTheme.colorScheme.surfaceContainerHigh, + tonalElevation = 2.dp, + shadowElevation = 2.dp, + ) { + Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)) { + Text( + text = bar.key, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + val bucket = bar.bucket + Text( + text = if (bucket == null) { + stringResource(R.string.settings_usage_tooltip_empty) + } else { + stringResource( + R.string.settings_usage_tooltip, + UsageMath.formatTokens(bucket.totalTokens), + bucket.requests, + UsageMath.formatTokens(bucket.inputTokens), + UsageMath.formatTokens(bucket.outputTokens), + ) + }, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +private fun DrawScope.drawDailyBars( + bars: List, + maxTokens: Long, + selectedIndex: Int?, + plotBottomInset: Float, + barColor: Color, + gridColor: Color, + labelStyle: TextStyle, + textMeasurer: TextMeasurer, +) { + if (bars.isEmpty()) return + val plotHeight = size.height - plotBottomInset + if (plotHeight <= 0f) return + val baselineY = plotHeight + + // Recessive scale: hairline baseline + max/mid gridlines with value labels. + drawLine(gridColor, Offset(0f, baselineY), Offset(size.width, baselineY), strokeWidth = 1.dp.toPx()) + val topPad = 14.dp.toPx() // room for the max label above its gridline + for ((fraction, value) in listOf(1f to maxTokens, 0.5f to maxTokens / 2)) { + val y = baselineY - (plotHeight - topPad) * fraction + drawLine(gridColor.copy(alpha = 0.5f), Offset(0f, y), Offset(size.width, y), strokeWidth = 1f) + drawText( + textMeasurer = textMeasurer, + text = UsageMath.formatTokens(value), + topLeft = Offset(0f, (y - 12.sp.toPx()).coerceAtLeast(0f)), + style = labelStyle, + ) + } + + val slot = size.width / bars.size + val gap = max(2.dp.toPx(), slot * 0.15f).coerceAtMost(slot / 2) + val barWidth = max(slot - gap, 1f) + val corner = CornerRadius(min(4.dp.toPx(), barWidth / 2f), min(4.dp.toPx(), barWidth / 2f)) + + bars.forEachIndexed { index, bar -> + if (bar.totalTokens <= 0) return@forEachIndexed + val barHeight = max( + (plotHeight - topPad) * (bar.totalTokens.toFloat() / maxTokens), + 2.dp.toPx(), + ) + val left = slot * index + (slot - barWidth) / 2f + val dimmed = selectedIndex != null && selectedIndex != index + // Rounded data-end at the top, flat edge on the baseline. + val path = Path().apply { + addRoundRect( + RoundRect( + rect = Rect( + offset = Offset(left, baselineY - barHeight), + size = Size(barWidth, barHeight), + ), + topLeft = corner, + topRight = corner, + bottomLeft = CornerRadius.Zero, + bottomRight = CornerRadius.Zero, + ), + ) + } + drawPath(path, color = if (dimmed) barColor.copy(alpha = 0.45f) else barColor) + } + + // First / middle / last day labels, clamped into the canvas. + val labelIndexes = buildSet { + add(0) + add(bars.lastIndex) + if (bars.size >= 5) add(bars.size / 2) + } + for (index in labelIndexes) { + val text = UsageMath.shortDayLabel(bars[index].key) + val measured = textMeasurer.measure(text, labelStyle) + val centerX = slot * (index + 0.5f) + val x = (centerX - measured.size.width / 2f) + .coerceIn(0f, max(0f, size.width - measured.size.width)) + drawText( + textMeasurer = textMeasurer, + text = text, + topLeft = Offset(x, baselineY + 4.dp.toPx()), + style = labelStyle, + ) + } +} + +private val LABEL_ROW_HEIGHT: Dp = 20.dp diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/settings/UsageMath.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/settings/UsageMath.kt new file mode 100644 index 0000000000..ea2ca2ba88 --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/settings/UsageMath.kt @@ -0,0 +1,99 @@ +package app.hapi.companion.feature.settings + +import app.hapi.protocol.wire.UsageSummaryBucket +import app.hapi.protocol.wire.UsageSummaryTotals +import java.time.LocalDate +import java.util.Locale +import kotlin.math.floor +import kotlin.math.ln +import kotlin.math.min +import kotlin.math.roundToLong + +/** + * Pure derivations behind the usage/storage dashboards — formatting twins of + * the web reference (`web/src/routes/settings/usage.tsx` `formatTokens`, + * `web/src/lib/file-metadata.ts` `formatFileSize`) plus the daily-bar series + * builder. Kept view-free for JVM tests. + */ +object UsageMath { + + /** `1234` → `1.2K`, `4560000` → `4.6M` … (web `formatTokens` thresholds). */ + fun formatTokens(value: Long): String = when { + value < 1_000 -> value.toString() + value < 1_000_000 -> + "${fixed(value / 1_000.0, decimals = if (value < 10_000) 1 else 0)}K" + value < 1_000_000_000 -> + "${fixed(value / 1_000_000.0, decimals = if (value < 10_000_000) 1 else 0)}M" + else -> "${fixed(value / 1_000_000_000.0, decimals = 1)}B" + } + + /** + * `87231` → `85.2 KB` (web `formatFileSize`): 1024 steps, ≥ 10 rounds to + * an integer, < 10 keeps one decimal with a trailing `.0` stripped. + */ + fun formatBytes(bytes: Long): String { + if (bytes < 0) return "0 B" + if (bytes < 1024) return "$bytes B" + val unitIndex = min(floor(ln(bytes.toDouble()) / ln(1024.0)).toInt(), BYTE_UNITS.lastIndex) + val value = bytes / Math.pow(1024.0, unitIndex.toDouble()) + val formatted = if (value >= 10) { + value.roundToLong().toString() + } else { + fixed(value, decimals = 1).removeSuffix(".0") + } + return "$formatted ${BYTE_UNITS[unitIndex]}" + } + + /** `cacheReadTokens / inputTokens` as `"37.4%"`; `"0%"` when there is no input. */ + fun cacheHitRate(totals: UsageSummaryTotals): String = + if (totals.inputTokens > 0) { + "${fixed(totals.cacheReadTokens * 100.0 / totals.inputTokens, decimals = 1)}%" + } else { + "0%" + } + + /** One bar of the daily chart; [bucket] is null for a zero-usage fill day. */ + data class DailyBar( + /** `YYYY-MM-DD` in the summary's timeZone. */ + val key: String, + val totalTokens: Long, + val bucket: UsageSummaryBucket? = null, + ) + + /** + * Bars for the daily chart. The hub's `daily` list is sparse (only days + * with usage); a bounded range ([days] = 7/30) is filled to a complete + * calendar window ending [today] so the time axis is honest. `range=all` + * ([days] = null) keeps the sparse buckets as-is — the span is unbounded. + */ + fun dailyBars(daily: List, days: Int?, today: LocalDate): List { + if (days == null) { + return daily.map { DailyBar(key = it.key, totalTokens = it.totalTokens, bucket = it) } + } + val byKey = daily.associateBy { it.key } + return (days - 1 downTo 0).map { offset -> + val key = today.minusDays(offset.toLong()).toString() + val bucket = byKey[key] + DailyBar(key = key, totalTokens = bucket?.totalTokens ?: 0, bucket = bucket) + } + } + + /** + * Bar slot under a tap at [x] px in a chart [width] px wide holding + * [count] equal slots; null when outside `[0, width)` or the chart is empty. + */ + fun barIndexAt(x: Float, width: Float, count: Int): Int? { + if (count <= 0 || width <= 0f || x < 0f || x >= width) return null + return min((x / (width / count)).toInt(), count - 1) + } + + /** `"2026-08-07"` → `"08-07"` for compact x-axis labels; junk passes through. */ + fun shortDayLabel(key: String): String = + if (key.length == 10 && key[4] == '-') key.substring(5) else key + + /** JS `toFixed` twin: US decimal separator, HALF_UP is close enough here. */ + private fun fixed(value: Double, decimals: Int): String = + String.format(Locale.US, "%.${decimals}f", value) + + private val BYTE_UNITS = listOf("B", "KB", "MB", "GB", "TB") +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/settings/UsageScreen.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/settings/UsageScreen.kt new file mode 100644 index 0000000000..a430f3d24b --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/settings/UsageScreen.kt @@ -0,0 +1,310 @@ +package app.hapi.companion.feature.settings + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import app.hapi.companion.R +import app.hapi.protocol.wire.UsageSummaryBucket +import app.hapi.protocol.wire.UsageSummaryResponse +import kotlin.math.max + +/** + * Owner-only token-usage dashboard (web `web/src/routes/settings/usage.tsx` + * twin): range segmented control, stat tiles, the Canvas daily bar chart, and + * byAgent/byModel bar lists. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun UsageScreen( + viewModel: UsageViewModel, + onBack: () -> Unit, +) { + LaunchedEffect(viewModel) { viewModel.start() } + val range by viewModel.range.collectAsState() + val state by viewModel.state.collectAsState() + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.settings_usage)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.settings_back), + ) + } + }, + ) + }, + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + UsageRangeControl(selected = range, onSelect = viewModel::setRange) + + when (val current = state) { + is UsageUiState.Loading -> Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 48.dp), + contentAlignment = Alignment.Center, + ) { CircularProgressIndicator() } + + is UsageUiState.Error -> DashboardError( + isForbidden = current.isForbidden, + message = current.message, + onRetry = viewModel::retry, + ) + + is UsageUiState.Data -> UsageSummaryContent( + summary = current.summary, + dailyBars = current.dailyBars, + ) + } + } + } +} + +@Composable +private fun UsageRangeControl(selected: UsageRange, onSelect: (UsageRange) -> Unit) { + val options = UsageRange.entries + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + options.forEachIndexed { index, option -> + SegmentedButton( + selected = option == selected, + onClick = { onSelect(option) }, + shape = SegmentedButtonDefaults.itemShape(index = index, count = options.size), + ) { + Text(stringResource(rangeLabelRes(option))) + } + } + } +} + +private fun rangeLabelRes(range: UsageRange): Int = when (range) { + UsageRange.SEVEN_DAYS -> R.string.settings_usage_range_7d + UsageRange.THIRTY_DAYS -> R.string.settings_usage_range_30d + UsageRange.ALL -> R.string.settings_usage_range_all +} + +/** The loaded dashboard body — also the preview entry point. */ +@Composable +internal fun UsageSummaryContent( + summary: UsageSummaryResponse, + dailyBars: List, +) { + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + UsageStatTiles(summary = summary) + + UsageSection(title = stringResource(R.string.settings_usage_daily_title)) { + if (summary.daily.isEmpty()) { + EmptyHint() + } else { + DailyBarChart( + bars = dailyBars, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 12.dp), + ) + } + } + + UsageSection(title = stringResource(R.string.settings_usage_by_agent)) { + UsageBarList(rows = summary.byAgent) + } + UsageSection(title = stringResource(R.string.settings_usage_by_model)) { + UsageBarList(rows = summary.byModel) + } + + Text( + text = stringResource(R.string.settings_usage_sessions, summary.totals.sessions), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +/** The eight headline tiles, two per row (web tile grid twin). */ +@Composable +internal fun UsageStatTiles(summary: UsageSummaryResponse) { + val totals = summary.totals + val tiles: List> = listOf( + R.string.settings_usage_total to UsageMath.formatTokens(totals.totalTokens), + R.string.settings_usage_uncached to UsageMath.formatTokens(totals.uncachedTokens), + R.string.settings_usage_input to UsageMath.formatTokens(totals.inputTokens), + R.string.settings_usage_output to UsageMath.formatTokens(totals.outputTokens), + R.string.settings_usage_cache_read to UsageMath.formatTokens(totals.cacheReadTokens), + R.string.settings_usage_cache_creation to UsageMath.formatTokens(totals.cacheCreationTokens), + R.string.settings_usage_cache_hit_rate to UsageMath.cacheHitRate(totals), + R.string.settings_usage_requests to UsageMath.formatTokens(totals.requests), + ) + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + tiles.chunked(2).forEach { rowTiles -> + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + rowTiles.forEach { (labelRes, value) -> + StatTile( + label = stringResource(labelRes), + value = value, + modifier = Modifier.weight(1f), + ) + } + } + } + } +} + +@Composable +private fun StatTile(label: String, value: String, modifier: Modifier = Modifier) { + Surface( + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = modifier, + ) { + Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp)) { + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = value, + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(top = 2.dp), + ) + } + } +} + +@Composable +private fun UsageSection(title: String, content: @Composable () -> Unit) { + Surface( + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = Modifier.fillMaxWidth(), + ) { + Column { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 12.dp, bottom = 4.dp), + ) + content() + } + } +} + +@Composable +private fun EmptyHint() { + Text( + text = stringResource(R.string.settings_usage_empty), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + ) +} + +/** + * Ranked share list (byAgent/byModel): name + tokens + a thin track bar + * scaled to the top row, sub-line with requests and in/out split. Top 8 rows, + * like the web. + */ +@Composable +internal fun UsageBarList(rows: List) { + if (rows.isEmpty()) { + EmptyHint() + return + } + val maxTokens = max(rows.first().totalTokens, 1L) + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { + rows.take(MAX_BUCKET_ROWS).forEach { row -> + Column(modifier = Modifier.padding(vertical = 6.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = row.key, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Text( + text = UsageMath.formatTokens(row.totalTokens), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 12.dp), + ) + } + ShareTrack(fraction = row.totalTokens.toFloat() / maxTokens) + Text( + text = stringResource( + R.string.settings_usage_bucket_details, + row.requests, + UsageMath.formatTokens(row.inputTokens), + UsageMath.formatTokens(row.outputTokens), + ), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) + } + } + } +} + +/** Thin rounded share bar (web 6px track twin); min 2% so tiny rows register. */ +@Composable +private fun ShareTrack(fraction: Float) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(top = 6.dp) + .height(6.dp) + .background(MaterialTheme.colorScheme.surfaceContainerHighest, RoundedCornerShape(3.dp)), + ) { + Box( + modifier = Modifier + .fillMaxWidth(fraction.coerceIn(0.02f, 1f)) + .height(6.dp) + .background(MaterialTheme.colorScheme.primary, RoundedCornerShape(3.dp)), + ) + } +} + +private const val MAX_BUCKET_ROWS = 8 diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/settings/UsageViewModel.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/settings/UsageViewModel.kt new file mode 100644 index 0000000000..b27bad38c3 --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/settings/UsageViewModel.kt @@ -0,0 +1,100 @@ +package app.hapi.companion.feature.settings + +import app.hapi.data.api.ApiError +import app.hapi.protocol.wire.UsageSummaryResponse +import java.time.LocalDate +import java.time.ZoneId +import kotlin.coroutines.cancellation.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +/** Wire values of the `range` query param (web `UsageRange`). */ +enum class UsageRange(val wireValue: String, val days: Int?) { + SEVEN_DAYS("7d", 7), + THIRTY_DAYS("30d", 30), + ALL("all", null), +} + +/** Transport seam (`HapiApi.getUsageSummary`) so JVM tests fake the hub. */ +fun interface UsageGateway { + suspend fun summary(range: String, timeZone: String): UsageSummaryResponse +} + +sealed interface UsageUiState { + data object Loading : UsageUiState + + data class Error( + val message: String?, + /** 403: the hub rejected a non-owner namespace — explain, don't retry. */ + val isForbidden: Boolean, + ) : UsageUiState + + data class Data( + val summary: UsageSummaryResponse, + /** Chart series: calendar-filled for 7d/30d, sparse for all. */ + val dailyBars: List, + ) : UsageUiState +} + +/** + * Usage-dashboard state: one in-flight load per range selection (a range + * switch cancels the previous fetch), device zone for both the `timeZone` + * param and the calendar fill so the two agree on day keys. + */ +class UsageViewModel( + private val gateway: UsageGateway, + private val scope: CoroutineScope, + private val zone: ZoneId = ZoneId.systemDefault(), + /** Injectable for deterministic calendar-fill tests. */ + private val today: () -> LocalDate = { LocalDate.now(zone) }, +) { + + private val mutableRange = MutableStateFlow(UsageRange.SEVEN_DAYS) + + val range: StateFlow = mutableRange.asStateFlow() + + private val mutableState = MutableStateFlow(UsageUiState.Loading) + + val state: StateFlow = mutableState.asStateFlow() + + private var loadJob: Job? = null + + fun start() { + if (loadJob == null) load(mutableRange.value) + } + + fun setRange(range: UsageRange) { + if (mutableRange.value == range) return + mutableRange.value = range + load(range) + } + + fun retry() { + load(mutableRange.value) + } + + private fun load(range: UsageRange) { + loadJob?.cancel() + mutableState.value = UsageUiState.Loading + loadJob = scope.launch { + mutableState.value = try { + val summary = gateway.summary(range.wireValue, zone.id) + UsageUiState.Data( + summary = summary, + dailyBars = UsageMath.dailyBars(summary.daily, range.days, today()), + ) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + UsageUiState.Error( + message = e.message, + isForbidden = (e as? ApiError)?.status == 403, + ) + } + } + } +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/push/DataStorePushDeviceIds.kt b/android/app/src/main/kotlin/app/hapi/companion/push/DataStorePushDeviceIds.kt new file mode 100644 index 0000000000..79170b310a --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/push/DataStorePushDeviceIds.kt @@ -0,0 +1,37 @@ +package app.hapi.companion.push + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import app.hapi.data.push.PushDeviceIdSource +import java.util.UUID +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * Stable install id for device registration (`deviceId` in + * `POST /api/devices/register`): a UUID minted on first use and persisted in + * the app-wide Preferences DataStore, so the hub's + * `(namespace, deviceId, platform)` upsert always hits the same row for this + * install. Cleared only by app data wipe / reinstall — which also rotates the + * FCM token, so a fresh id is correct then. + */ +class DataStorePushDeviceIds( + private val dataStore: DataStore, +) : PushDeviceIdSource { + + private val mutex = Mutex() + + override suspend fun deviceId(): String = mutex.withLock { + dataStore.data.first()[KEY]?.takeIf { it.isNotBlank() } + ?: UUID.randomUUID().toString().also { minted -> + dataStore.edit { prefs -> prefs[KEY] = minted } + } + } + + private companion object { + val KEY = stringPreferencesKey("push_device_id") + } +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/push/PushBinding.kt b/android/app/src/main/kotlin/app/hapi/companion/push/PushBinding.kt new file mode 100644 index 0000000000..e7a0d29237 --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/push/PushBinding.kt @@ -0,0 +1,62 @@ +package app.hapi.companion.push + +import android.content.Context +import com.google.android.gms.tasks.Task +import com.google.firebase.FirebaseApp +import com.google.firebase.messaging.FirebaseMessaging +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlinx.coroutines.suspendCancellableCoroutine + +/** + * The one seam between the app and Firebase (B-M4a). firebase-messaging is + * always on the classpath, but the SDK only *activates* when a Firebase + * project is bound — today via `app/google-services.json` (the + * google-services plugin is applied conditionally; without the file, + * `FirebaseInitProvider` finds no default options and initializes nothing). + * + * Every push code path (registrar, FCM service, workers, permission prompt) + * checks [isAvailable] / gets a null [currentToken] and no-ops cleanly when + * Firebase isn't configured — a config-less self-build behaves exactly like + * pre-M4a, just without push. + * + * v1.x path (planned, per the native-clients plan): bind Firebase at runtime + * from hub-provided config instead — `FirebaseApp.initializeApp(context, + * FirebaseOptions.Builder()…)` with values the hub serves alongside pairing. + * That lands entirely behind this object: [isAvailable] flips true once the + * runtime init succeeds, and nothing else in the app changes. + */ +object PushBinding { + + /** True when a [FirebaseApp] is bound (google-services.json present). */ + fun isAvailable(context: Context): Boolean = try { + FirebaseApp.getApps(context.applicationContext).isNotEmpty() + } catch (_: Throwable) { + false // defensive: a broken Firebase runtime must never take the app down + } + + /** + * Current FCM registration token, or null when push is unavailable or + * the fetch failed (offline first start — `onNewToken` covers us later). + */ + suspend fun currentToken(context: Context): String? { + if (!isAvailable(context)) return null + return try { + FirebaseMessaging.getInstance().token.awaitTask() + } catch (_: Exception) { + null + } + } +} + +/** Minimal Task-to-coroutine bridge (avoids the play-services coroutines dep). */ +private suspend fun Task.awaitTask(): T = suspendCancellableCoroutine { continuation -> + addOnCompleteListener { task -> + val error = task.exception + when { + error != null -> continuation.resumeWithException(error) + task.isCanceled -> continuation.cancel() + else -> continuation.resume(task.result) + } + } +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/ui/components/AgentFlavorIcon.kt b/android/app/src/main/kotlin/app/hapi/companion/ui/components/AgentFlavorIcon.kt new file mode 100644 index 0000000000..63c6a62cb2 --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/ui/components/AgentFlavorIcon.kt @@ -0,0 +1,136 @@ +package app.hapi.companion.ui.components + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import app.hapi.companion.R +import app.hapi.companion.ui.theme.HapiTheme +import app.hapi.companion.ui.theme.hapi + +/** + * Brand logo per agent flavor — port of `web/src/components/AgentFlavorIcon.tsx` + * (brand SVGs via @lobehub/icons, the same source the web ships). + * + * Color variants (agy/claude/codex/gemini) keep their literal brand fills and + * render untinted so they stay visible on both light and dark surfaces — the + * reason the web picked Color for them. Mono variants (cursor/grok/kimi/ + * opencode/pi) are currentColor upstream and tint with [LocalContentColor]; + * copilot mirrors the web's fixed GitHub-mark tint (#24292F light / + * #E6EDF3 dark). Unknown flavors fall back to the web's "Un" badge. + */ +@Composable +fun AgentFlavorIcon(flavor: String?, modifier: Modifier = Modifier.size(16.dp)) { + when (val normalized = flavor?.trim()?.lowercase().orEmpty()) { + in COLOR_FLAVOR_ICONS -> Image( + painter = painterResource(COLOR_FLAVOR_ICONS.getValue(normalized)), + contentDescription = null, + modifier = modifier, + ) + + in MONO_FLAVOR_ICONS -> Icon( + painter = painterResource(MONO_FLAVOR_ICONS.getValue(normalized)), + contentDescription = null, + modifier = modifier, + tint = LocalContentColor.current, + ) + + "copilot" -> Icon( + painter = painterResource(R.drawable.ic_agent_copilot), + contentDescription = null, + modifier = modifier, + tint = if (MaterialTheme.hapi.isDark) Color(0xFFE6EDF3) else Color(0xFF24292F), + ) + + else -> Box( + modifier = modifier.background( + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = RoundedCornerShape(3.dp), + ), + contentAlignment = Alignment.Center, + ) { + Text( + text = "Un", + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 8.sp, + lineHeight = 8.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + softWrap = false, + ) + } + } +} + +private val COLOR_FLAVOR_ICONS: Map = mapOf( + "agy" to R.drawable.ic_agent_agy, + "claude" to R.drawable.ic_agent_claude, + "codex" to R.drawable.ic_agent_codex, + "gemini" to R.drawable.ic_agent_gemini, +) + +private val MONO_FLAVOR_ICONS: Map = mapOf( + "cursor" to R.drawable.ic_agent_cursor, + "grok" to R.drawable.ic_agent_grok, + "kimi" to R.drawable.ic_agent_kimi, + "opencode" to R.drawable.ic_agent_opencode, + "pi" to R.drawable.ic_agent_pi, +) + +// -------------------------------------------------------------- preview -- + +@Composable +private fun AgentFlavorIconStrip() { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + val flavors = listOf( + "agy", "claude", "codex", "copilot", "cursor", + "gemini", "grok", "kimi", "opencode", "pi", "mystery", + ) + flavors.chunked(6).forEach { chunk -> + Row( + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + chunk.forEach { flavor -> AgentFlavorIcon(flavor) } + } + } + } +} + +@Preview(showBackground = true, name = "Agent icons · light") +@Composable +private fun AgentFlavorIconPreviewLight() { + HapiTheme(darkTheme = false, dynamicColor = false) { + Surface { AgentFlavorIconStrip() } + } +} + +@Preview(showBackground = true, name = "Agent icons · dark") +@Composable +private fun AgentFlavorIconPreviewDark() { + HapiTheme(darkTheme = true, dynamicColor = false) { + Surface { AgentFlavorIconStrip() } + } +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/ui/components/DiffView.kt b/android/app/src/main/kotlin/app/hapi/companion/ui/components/DiffView.kt new file mode 100644 index 0000000000..c5f4acfe4c --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/ui/components/DiffView.kt @@ -0,0 +1,226 @@ +package app.hapi.companion.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import app.hapi.companion.R +import app.hapi.companion.ui.theme.HapiExtendedColors +import app.hapi.companion.ui.theme.hapi +import app.hapi.protocol.git.DiffChangeKind +import app.hapi.protocol.git.DiffFile +import app.hapi.protocol.git.DiffLineKind + +/** + * Renders one parsed [DiffFile] (`app.hapi.protocol.git.UnifiedDiffParser`): + * header with path + add/remove badges, hunk headers, +/- tinted rows with + * dual line-number gutters, horizontal scroll. Starts compact ([compact]) and + * expands in place beyond [compactLineLimit] rows. + */ +@Composable +fun DiffView( + file: DiffFile, + modifier: Modifier = Modifier, + compact: Boolean = true, + compactLineLimit: Int = 12, +) { + val colors = MaterialTheme.hapi + var expanded by rememberSaveable(file.displayPath, file.hunks.size) { mutableStateOf(!compact) } + + Column( + modifier = modifier + .clip(RoundedCornerShape(12.dp)) + .background(colors.codeBackground), + ) { + DiffHeader(file, colors) + + if (file.isBinary) { + Text( + text = stringResource(R.string.diff_binary_file), + fontSize = 12.sp, + color = colors.hint, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + ) + return@Column + } + if (file.hunks.isEmpty()) return@Column + + val rows = remember(file, colors) { buildDiffRows(file, colors) } + val visible = if (expanded) rows else rows.take(compactLineLimit) + val hidden = rows.size - visible.size + + Column( + modifier = Modifier + .horizontalScroll(rememberScrollState()) + .width(IntrinsicSize.Max) + .padding(vertical = 4.dp), + ) { + for (row in visible) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(row.background), + ) { + Text( + text = row.text, + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + lineHeight = 18.sp, + softWrap = false, + maxLines = 1, + modifier = Modifier.padding(horizontal = 10.dp), + ) + } + } + } + + if (hidden > 0 || (expanded && rows.size > compactLineLimit)) { + Text( + text = if (hidden > 0) { + stringResource(R.string.diff_show_more, hidden) + } else { + stringResource(R.string.diff_collapse) + }, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier + .fillMaxWidth() + .clickable { expanded = !expanded } + .padding(horizontal = 12.dp, vertical = 8.dp), + ) + } + } +} + +@Composable +private fun DiffHeader(file: DiffFile, colors: HapiExtendedColors) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(colors.codeHeaderBackground) + .padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + val title = when { + file.changeKind == DiffChangeKind.RENAME || file.changeKind == DiffChangeKind.COPY -> + "${file.oldPath ?: "?"} → ${file.newPath ?: "?"}" + else -> file.displayPath + } + Text( + text = title, + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + letterSpacing = 0.5.sp, + color = colors.codeHeaderForeground, + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis, + modifier = Modifier.weight(1f), + ) + val kindLabel = when (file.changeKind) { + DiffChangeKind.ADD -> "new" + DiffChangeKind.DELETE -> "deleted" + DiffChangeKind.RENAME -> "renamed" + DiffChangeKind.COPY -> "copied" + DiffChangeKind.MODIFY -> null + } + if (kindLabel != null) { + Text( + text = kindLabel, + fontSize = 10.sp, + color = colors.hint, + ) + } + DiffStatBadge(added = true, value = file.additions, colors = colors) + DiffStatBadge(added = false, value = file.deletions, colors = colors) + } +} + +@Composable +private fun DiffStatBadge(added: Boolean, value: Int, colors: HapiExtendedColors) { + Text( + text = (if (added) "+" else "-") + value, + fontSize = 10.sp, + fontWeight = FontWeight.Medium, + color = if (added) colors.diffAddText else colors.diffRemoveText, + modifier = Modifier + .clip(RoundedCornerShape(50)) + .background(if (added) colors.diffAddBackground else colors.diffRemoveBackground) + .padding(horizontal = 7.dp, vertical = 1.dp), + ) +} + +private class DiffRow(val text: AnnotatedString, val background: Color) + +private fun buildDiffRows(file: DiffFile, colors: HapiExtendedColors): List { + val maxOld = file.hunks.maxOf { hunk -> hunk.oldStart + hunk.oldCount } + val maxNew = file.hunks.maxOf { hunk -> hunk.newStart + hunk.newCount } + val oldWidth = maxOf(maxOld.toString().length, 2) + val newWidth = maxOf(maxNew.toString().length, 2) + val gutterStyle = SpanStyle(color = colors.hint.copy(alpha = 0.8f)) + + val rows = mutableListOf() + for (hunk in file.hunks) { + rows += DiffRow( + text = buildAnnotatedString { + withStyle(SpanStyle(color = colors.hint)) { append(hunk.header) } + }, + background = colors.diffHunkBackground, + ) + for (line in hunk.lines) { + val (marker, background, textColor) = when (line.kind) { + DiffLineKind.ADD -> Triple("+", colors.diffAddBackground, colors.diffAddText) + DiffLineKind.REMOVE -> Triple("-", colors.diffRemoveBackground, colors.diffRemoveText) + DiffLineKind.CONTEXT -> Triple(" ", Color.Transparent, colors.inlineCodeForeground) + } + rows += DiffRow( + text = buildAnnotatedString { + withStyle(gutterStyle) { + append((line.oldLineNumber?.toString() ?: "").padStart(oldWidth)) + append(' ') + append((line.newLineNumber?.toString() ?: "").padStart(newWidth)) + } + withStyle(SpanStyle(color = textColor)) { + append(" ") + append(marker) + append(' ') + append(line.text) + } + }, + background = background, + ) + } + } + return rows +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/ui/markdown/CodeBlock.kt b/android/app/src/main/kotlin/app/hapi/companion/ui/markdown/CodeBlock.kt new file mode 100644 index 0000000000..9aa8db2345 --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/ui/markdown/CodeBlock.kt @@ -0,0 +1,249 @@ +package app.hapi.companion.ui.markdown + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import app.hapi.companion.R +import app.hapi.companion.ui.theme.HapiExtendedColors +import app.hapi.companion.ui.theme.hapi +import dev.snipme.highlights.Highlights +import dev.snipme.highlights.model.BoldHighlight +import dev.snipme.highlights.model.CodeHighlight +import dev.snipme.highlights.model.ColorHighlight +import dev.snipme.highlights.model.SyntaxLanguage +import dev.snipme.highlights.model.SyntaxThemes +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext + +/** Above this many lines highlighting is skipped and the block renders plain. */ +private const val MAX_HIGHLIGHT_LINES = 400 + +private const val HIGHLIGHT_CACHE_SIZE = 200 + +private data class HighlightKey( + val codeHash: Int, + val codeLength: Int, + val language: String?, + val dark: Boolean, +) + +/** LRU (access-order) cache of computed highlight spans, shared app-wide. */ +private object HighlightCache { + private val map = object : LinkedHashMap>(64, 0.75f, true) { + override fun removeEldestEntry( + eldest: MutableMap.MutableEntry>, + ): Boolean = size > HIGHLIGHT_CACHE_SIZE + } + + @Synchronized + fun get(key: HighlightKey): List? = map[key] + + @Synchronized + fun put(key: HighlightKey, value: List) { + map[key] = value + } +} + +/** + * Fenced-code surface: language chip + copy button header, horizontally + * scrolling monospaced body with `highlights`-based syntax coloring computed + * off the main thread ([produceState] + LRU cache). Long blocks (> + * [MAX_HIGHLIGHT_LINES] lines) and unknown languages stay plain. + */ +@Composable +fun CodeBlock(code: String, language: String?, modifier: Modifier = Modifier) { + val colors = MaterialTheme.hapi + + Column( + modifier = modifier + .clip(RoundedCornerShape(12.dp)) + .background(colors.codeBackground), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(colors.codeHeaderBackground) + .padding(start = 12.dp, end = 6.dp, top = 4.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = (language ?: stringResource(R.string.code_plain_fallback)).uppercase(), + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + letterSpacing = 0.8.sp, + color = colors.codeHeaderForeground, + maxLines = 1, + modifier = Modifier.weight(1f, fill = false), + ) + Spacer(Modifier.weight(1f)) + CopyButton(code, colors) + } + val highlighted = rememberHighlightedCode(code, language, colors.isDark) + Box( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + ) { + Text( + text = highlighted, + fontFamily = FontFamily.Monospace, + fontSize = 13.sp, + lineHeight = 19.sp, + softWrap = false, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + ) + } + } +} + +@Composable +private fun CopyButton(code: String, colors: HapiExtendedColors) { + // LocalClipboard (the suspend replacement) buys nothing for a plain text + // copy and would force a scope launch in the click handler; the deprecated + // sync API is the deliberate choice here. + @Suppress("DEPRECATION") + val clipboard = LocalClipboardManager.current + var copied by remember { mutableStateOf(false) } + LaunchedEffect(copied) { + if (copied) { + delay(1600) + copied = false + } + } + Text( + text = stringResource(if (copied) R.string.code_copied else R.string.code_copy), + fontSize = 11.sp, + fontWeight = FontWeight.Medium, + color = if (copied) MaterialTheme.colorScheme.primary else colors.codeHeaderForeground, + modifier = Modifier + .clip(RoundedCornerShape(6.dp)) + .clickable { + clipboard.setText(AnnotatedString(code)) + copied = true + } + .padding(horizontal = 8.dp, vertical = 4.dp), + ) +} + +@Composable +private fun rememberHighlightedCode(code: String, language: String?, dark: Boolean): AnnotatedString { + val eligible = language != null && + code.isNotBlank() && + countLines(code) <= MAX_HIGHLIGHT_LINES && + resolveLanguage(language) != SyntaxLanguage.DEFAULT + val key = HighlightKey(code.hashCode(), code.length, language?.lowercase(), dark) + + val highlighted by produceState( + initialValue = HighlightCache.get(key)?.let { applyHighlights(code, it) } ?: AnnotatedString(code), + key1 = key, + ) { + // Producer restarts on key change: reset synchronously (cache or plain) + // so a recycled composable never shows spans from the previous code. + val cached = HighlightCache.get(key) + value = cached?.let { applyHighlights(code, it) } ?: AnnotatedString(code) + if (cached == null && eligible) { + val spans = withContext(Dispatchers.Default) { + computeHighlights(code, language!!, dark) + } + HighlightCache.put(key, spans) + value = applyHighlights(code, spans) + } + } + return highlighted +} + +private fun countLines(code: String): Int { + var lines = 1 + for (ch in code) if (ch == '\n') lines += 1 + return lines +} + +private val LANGUAGE_ALIASES = mapOf( + "js" to "javascript", + "jsx" to "javascript", + "mjs" to "javascript", + "cjs" to "javascript", + "ts" to "typescript", + "tsx" to "typescript", + "py" to "python", + "rb" to "ruby", + "kts" to "kotlin", + "sh" to "shell", + "bash" to "shell", + "zsh" to "shell", + "shellsession" to "shell", + "c++" to "cpp", + "cs" to "csharp", + "golang" to "go", +) + +private fun resolveLanguage(raw: String?): SyntaxLanguage { + if (raw == null) return SyntaxLanguage.DEFAULT + val name = LANGUAGE_ALIASES[raw.lowercase()] ?: raw.lowercase() + return SyntaxLanguage.values().firstOrNull { it.name.equals(name, ignoreCase = true) } + ?: SyntaxLanguage.DEFAULT +} + +private fun computeHighlights(code: String, language: String, dark: Boolean): List = + try { + Highlights.Builder() + .code(code) + .language(resolveLanguage(language)) + .theme(SyntaxThemes.darcula(darkMode = dark)) + .build() + .getHighlights() + } catch (_: Exception) { + // Highlighting is cosmetic; malformed input must never take the UI down. + emptyList() + } + +private fun applyHighlights(code: String, highlights: List): AnnotatedString { + if (highlights.isEmpty()) return AnnotatedString(code) + return buildAnnotatedString { + append(code) + for (highlight in highlights) { + val start = highlight.location.start.coerceIn(0, code.length) + val end = highlight.location.end.coerceIn(0, code.length) + if (end <= start) continue + when (highlight) { + is ColorHighlight -> addStyle( + SpanStyle(color = Color(0xFF000000.toInt() or highlight.rgb)), + start, + end, + ) + is BoldHighlight -> addStyle(SpanStyle(fontWeight = FontWeight.Bold), start, end) + } + } + } +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/ui/markdown/Markdown.kt b/android/app/src/main/kotlin/app/hapi/companion/ui/markdown/Markdown.kt new file mode 100644 index 0000000000..3489f14ae6 --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/ui/markdown/Markdown.kt @@ -0,0 +1,593 @@ +package app.hapi.companion.ui.markdown + +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.Measurable +import androidx.compose.ui.layout.Placeable +import androidx.compose.ui.layout.layoutId +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.LinkInteractionListener +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withLink +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.em +import androidx.compose.ui.unit.sp +import app.hapi.companion.ui.theme.HapiExtendedColors +import app.hapi.companion.ui.theme.hapi +import app.hapi.protocol.markdown.HrefDecision +import app.hapi.protocol.markdown.HrefPolicy +import app.hapi.protocol.markdown.MarkdownTransforms +import org.commonmark.ext.gfm.strikethrough.Strikethrough +import org.commonmark.ext.gfm.tables.TableBlock +import org.commonmark.ext.gfm.tables.TableCell +import org.commonmark.ext.gfm.tables.TableHead +import org.commonmark.ext.gfm.tables.TableRow +import org.commonmark.node.BlockQuote +import org.commonmark.node.BulletList +import org.commonmark.node.Code +import org.commonmark.node.Emphasis +import org.commonmark.node.FencedCodeBlock +import org.commonmark.node.HardLineBreak +import org.commonmark.node.Heading +import org.commonmark.node.HtmlBlock +import org.commonmark.node.HtmlInline +import org.commonmark.node.Image +import org.commonmark.node.IndentedCodeBlock +import org.commonmark.node.Link +import org.commonmark.node.ListItem +import org.commonmark.node.Node +import org.commonmark.node.OrderedList +import org.commonmark.node.Paragraph +import org.commonmark.node.SoftLineBreak +import org.commonmark.node.StrongEmphasis +import org.commonmark.node.Text as MdText +import org.commonmark.node.ThematicBreak + +// ── Link handling ──────────────────────────────────────────────────────────── + +/** + * Receives clicks on markdown links. The chat screen provides a confirm-aware + * URL opener (`rememberChatLinkHandler`); file taps route to the session file + * viewer once it exists (M4). + */ +interface MarkdownLinkHandler { + /** A workspace file citation (`src/a.ts`, `hub/src/x.ts:345`, ...). */ + fun onFilePath(path: String, line: Int?) + + /** + * A URL whose [HrefPolicy] decision is [HrefDecision.Allowed] or + * [HrefDecision.ConfirmFirst]; blocked destinations never reach here -- + * they render as inert text. + */ + fun onUrl(url: String, decision: HrefDecision) +} + +private object NoOpMarkdownLinkHandler : MarkdownLinkHandler { + override fun onFilePath(path: String, line: Int?) = Unit + override fun onUrl(url: String, decision: HrefDecision) = Unit +} + +val LocalMarkdownLinkHandler = staticCompositionLocalOf { NoOpMarkdownLinkHandler } + +// ── Entry point ────────────────────────────────────────────────────────────── + +/** + * Renders chat markdown with the shared HAPI pipeline: source transforms and + * parser configuration come from `:core:protocol` ([MarkdownTransforms.parse]: + * table repair, GFM tables/strikethrough/autolink, indented code disabled), + * then this walker maps the AST to Compose. Unknown fences (mermaid, math) + * degrade to plain [CodeBlock]s per the v1 plan; html blocks fall back to + * monospace text. + */ +@Composable +fun Markdown(text: String, modifier: Modifier = Modifier) { + val parsed = remember(text) { prepareDocument(text) } + val baseStyle = MaterialTheme.typography.bodyLarge.copy(fontSize = 15.sp, lineHeight = 22.sp) + CompositionLocalProvider(LocalTextStyle provides baseStyle) { + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(10.dp)) { + MarkdownBlockChildren(parsed.document, parsed) + } + } +} + +/** Parsed AST plus the task-list markers stripped out of it (identity-keyed). */ +private class ParsedMarkdown(val document: Node, val taskMarkers: Map) + +private val TASK_MARKER = Regex("""^\[([ xX])\]\s+""") + +/** + * Parse + normalize GFM task-list items (`- [x] done`): the textual marker is + * removed from the first text node and remembered per list item, so the walker + * can draw a checkbox prefix (the task extension jar is not needed for this). + */ +private fun prepareDocument(text: String): ParsedMarkdown { + val document = MarkdownTransforms.parse(text) + val markers = HashMap() + var node: Node? = document + val stack = ArrayDeque() + while (node != null) { + if (node is ListItem) { + val firstText = (node.firstChild as? Paragraph)?.firstChild as? MdText + val literal = firstText?.literal + val match = literal?.let { TASK_MARKER.find(it) } + if (firstText != null && match != null) { + markers[node] = match.groupValues[1] != " " + firstText.literal = literal.removeRange(match.range) + } + } + var child = node.firstChild + while (child != null) { + stack.addLast(child) + child = child.next + } + node = stack.removeLastOrNull() + } + return ParsedMarkdown(document, markers) +} + +// ── Block rendering ────────────────────────────────────────────────────────── + +@Composable +private fun MarkdownBlockChildren(parent: Node, parsed: ParsedMarkdown) { + var child = parent.firstChild + while (child != null) { + MarkdownBlock(child, parsed) + child = child.next + } +} + +@Composable +private fun MarkdownBlock(node: Node, parsed: ParsedMarkdown) { + when (node) { + is Paragraph -> MarkdownInlineText(node) + is Heading -> MarkdownInlineText(node, style = headingStyle(node.level)) + is BlockQuote -> MarkdownBlockQuote(node, parsed) + is BulletList -> MarkdownList(node, ordered = false, parsed = parsed) + is OrderedList -> MarkdownList(node, ordered = true, parsed = parsed) + is FencedCodeBlock -> CodeBlock( + code = node.literal.orEmpty().trimEnd('\n'), + language = node.info?.trim()?.takeWhile { !it.isWhitespace() }?.ifEmpty { null }, + ) + is IndentedCodeBlock -> CodeBlock(code = node.literal.orEmpty().trimEnd('\n'), language = null) + is TableBlock -> MarkdownTable(node) + is ThematicBreak -> HorizontalDivider(color = MaterialTheme.hapi.divider) + is HtmlBlock -> HtmlBlockFallback(node.literal.orEmpty()) + else -> if (node.firstChild != null) MarkdownBlockChildren(node, parsed) + } +} + +@Composable +private fun headingStyle(level: Int): TextStyle { + // Chat headings are compact (web: 1.05rem..0.88rem semibold). + val base = LocalTextStyle.current + val size = when (level) { + 1 -> 18.sp + 2 -> 17.sp + 3 -> 16.sp + else -> 15.sp + } + return base.copy(fontSize = size, lineHeight = size * 1.4, fontWeight = FontWeight.SemiBold) +} + +@Composable +private fun MarkdownBlockQuote(node: BlockQuote, parsed: ParsedMarkdown) { + val colors = MaterialTheme.hapi + Row( + modifier = Modifier + .height(IntrinsicSize.Min) + .clip(RoundedCornerShape(topEnd = 12.dp, bottomEnd = 12.dp)) + .background(colors.blockquoteBackground), + ) { + Box( + Modifier + .width(3.dp) + .fillMaxHeight() + .background(colors.blockquoteBar), + ) + CompositionLocalProvider(LocalContentColor provides colors.blockquoteForeground) { + Column( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + MarkdownBlockChildren(node, parsed) + } + } + } +} + +@Composable +private fun MarkdownList(list: Node, ordered: Boolean, parsed: ParsedMarkdown) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + var index = (list as? OrderedList)?.markerStartNumber ?: 1 + var item = list.firstChild + while (item != null) { + if (item is ListItem) { + val task = parsed.taskMarkers[item] + val marker = when { + task == true -> "☑" // checked box + task == false -> "☐" // empty box + ordered -> "$index." + else -> "•" // bullet + } + MarkdownListItem(item, marker, parsed) + index += 1 + } + item = item.next + } + } +} + +@Composable +private fun MarkdownListItem(item: ListItem, marker: String, parsed: ParsedMarkdown) { + Row { + Text( + text = marker, + style = LocalTextStyle.current, + color = MaterialTheme.hapi.hint, + modifier = Modifier + .widthIn(min = 22.dp) + .padding(end = 4.dp), + ) + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + MarkdownBlockChildren(item, parsed) + } + } +} + +@Composable +private fun HtmlBlockFallback(literal: String) { + Text( + text = literal.trimEnd('\n'), + style = LocalTextStyle.current.copy( + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + lineHeight = 17.sp, + ), + color = MaterialTheme.hapi.hint, + ) +} + +// ── Table rendering ────────────────────────────────────────────────────────── + +private class TableRowModel(val isHeader: Boolean, val cells: List) + +@Composable +private fun MarkdownTable(table: TableBlock) { + val colors = MaterialTheme.hapi + val rows = remember(table) { collectTableRows(table) } + if (rows.isEmpty()) return + val columnCount = rows.maxOf { it.cells.size } + + Box( + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .background(colors.tableBackground) + .horizontalScroll(rememberScrollState()), + ) { + TableGrid(rows, columnCount, colors) + } +} + +private fun collectTableRows(table: TableBlock): List { + val rows = mutableListOf() + var section = table.firstChild + while (section != null) { + val isHeader = section is TableHead + var row = section.firstChild + while (row != null) { + if (row is TableRow) { + val cells = mutableListOf() + var cell = row.firstChild + while (cell != null) { + if (cell is TableCell) cells.add(cell) + cell = cell.next + } + rows.add(TableRowModel(isHeader, cells)) + } + row = row.next + } + section = section.next + } + return rows +} + +/** + * Content-sized grid: column widths come from the widest cell (capped so long + * prose wraps), row backgrounds/dividers are dedicated measurables sized after + * the cells, placed behind them. The whole grid lives in a horizontal scroller. + */ +@Composable +private fun TableGrid(rows: List, columnCount: Int, colors: HapiExtendedColors) { + val cellStyle = LocalTextStyle.current.copy(fontSize = 13.sp, lineHeight = 19.sp) + Layout( + content = { + rows.forEachIndexed { r, row -> + Box( + Modifier + .layoutId("bg:$r") + .background(if (row.isHeader) colors.tableHeaderBackground else colors.tableBackground), + ) + row.cells.forEachIndexed { c, cell -> + val alignment = cell.alignment + Box( + modifier = Modifier + .layoutId("cell:$r:$c") + .padding(horizontal = 10.dp, vertical = 6.dp), + contentAlignment = when (alignment) { + TableCell.Alignment.CENTER -> Alignment.TopCenter + TableCell.Alignment.RIGHT -> Alignment.TopEnd + else -> Alignment.TopStart + }, + ) { + MarkdownInlineText( + cell, + style = if (row.isHeader) cellStyle.copy(fontWeight = FontWeight.SemiBold) else cellStyle, + textAlign = when (alignment) { + TableCell.Alignment.CENTER -> TextAlign.Center + TableCell.Alignment.RIGHT -> TextAlign.End + else -> TextAlign.Start + }, + ) + } + } + } + }, + ) { measurables, _ -> + val cellCap = 320.dp.roundToPx() + val cells = Array(rows.size) { arrayOfNulls(columnCount) } + val backgrounds = arrayOfNulls(rows.size) + for (measurable in measurables) { + val id = measurable.layoutId as String + val parts = id.split(':') + when (parts[0]) { + "bg" -> backgrounds[parts[1].toInt()] = measurable + "cell" -> cells[parts[1].toInt()][parts[2].toInt()] = measurable + } + } + + val columnWidths = IntArray(columnCount) + for (r in rows.indices) { + for (c in 0 until columnCount) { + val cell = cells[r][c] ?: continue + val intrinsic = cell.maxIntrinsicWidth(Constraints.Infinity) + columnWidths[c] = maxOf(columnWidths[c], minOf(intrinsic, cellCap)) + } + } + val tableWidth = columnWidths.sum() + + val placeables = Array(rows.size) { arrayOfNulls(columnCount) } + val rowHeights = IntArray(rows.size) + for (r in rows.indices) { + for (c in 0 until columnCount) { + val cell = cells[r][c] ?: continue + val placeable = cell.measure( + Constraints(minWidth = columnWidths[c], maxWidth = columnWidths[c]), + ) + placeables[r][c] = placeable + rowHeights[r] = maxOf(rowHeights[r], placeable.height) + } + } + val backgroundPlaceables = Array(rows.size) { r -> + backgrounds[r]?.measure(Constraints.fixed(tableWidth, rowHeights[r])) + } + + val tableHeight = rowHeights.sum() + layout(tableWidth, tableHeight) { + var y = 0 + for (r in rows.indices) { + backgroundPlaceables[r]?.place(0, y) + var x = 0 + for (c in 0 until columnCount) { + placeables[r][c]?.place(x, y) + x += columnWidths[c] + } + y += rowHeights[r] + } + } + } +} + +// ── Inline rendering ───────────────────────────────────────────────────────── + +@Composable +private fun MarkdownInlineText( + parent: Node, + style: TextStyle = LocalTextStyle.current, + textAlign: TextAlign? = null, +) { + val handler = LocalMarkdownLinkHandler.current + val colors = MaterialTheme.hapi + val annotated = remember(parent, colors, handler) { + buildAnnotatedString { + appendInlineChildren(parent, InlineContext(colors, handler), insideLink = false) + } + } + Text( + text = annotated, + style = if (textAlign != null) style.copy(textAlign = textAlign) else style, + ) +} + +private class InlineContext(val colors: HapiExtendedColors, val handler: MarkdownLinkHandler) { + val linkStyles = TextLinkStyles( + style = SpanStyle( + color = colors.link, + fontWeight = FontWeight.Medium, + textDecoration = TextDecoration.Underline, + ), + ) + val codeSpanStyle = SpanStyle( + fontFamily = FontFamily.Monospace, + fontSize = 0.9.em, + background = colors.inlineCodeBackground, + color = colors.inlineCodeForeground, + ) +} + +private fun AnnotatedString.Builder.appendInlineChildren( + parent: Node, + ctx: InlineContext, + insideLink: Boolean, +) { + var node = parent.firstChild + while (node != null) { + appendInlineNode(node, ctx, insideLink) + node = node.next + } +} + +private fun AnnotatedString.Builder.appendInlineNode(node: Node, ctx: InlineContext, insideLink: Boolean) { + when (node) { + is MdText -> { + val literal = node.literal.orEmpty() + if (insideLink) append(literal) else appendTextWithFilePaths(literal, ctx) + } + // Soft breaks collapse to spaces on the assistant surface (web parity; + // user prompts get a breaks-preserving variant later). + is SoftLineBreak -> append(' ') + is HardLineBreak -> append('\n') + is Emphasis -> withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { + appendInlineChildren(node, ctx, insideLink) + } + is StrongEmphasis -> withStyle(SpanStyle(fontWeight = FontWeight.SemiBold)) { + appendInlineChildren(node, ctx, insideLink) + } + is Strikethrough -> withStyle(SpanStyle(textDecoration = TextDecoration.LineThrough)) { + appendInlineChildren(node, ctx, insideLink) + } + is Code -> appendInlineCode(node.literal.orEmpty(), ctx, insideLink) + is Link -> if (insideLink) appendInlineChildren(node, ctx, true) else appendLink(node, ctx) + is Image -> appendImageFallback(node, ctx) + is HtmlInline -> append(node.literal.orEmpty()) + else -> appendInlineChildren(node, ctx, insideLink) + } +} + +/** Plain prose: autolink workspace file citations found by the shared detector. */ +private fun AnnotatedString.Builder.appendTextWithFilePaths(literal: String, ctx: InlineContext) { + val links = MarkdownTransforms.detectFilePathLinks(literal) + if (links.isEmpty()) { + append(literal) + return + } + var cursor = 0 + for (link in links) { + if (link.range.first > cursor) append(literal.substring(cursor, link.range.first)) + appendFilePathLink(link.display, link.path, link.line, ctx, monospace = false) + cursor = link.range.last + 1 + } + if (cursor < literal.length) append(literal.substring(cursor)) +} + +private fun AnnotatedString.Builder.appendFilePathLink( + display: String, + path: String, + line: Int?, + ctx: InlineContext, + monospace: Boolean, +) { + val listener = LinkInteractionListener { ctx.handler.onFilePath(path, line) } + withLink(LinkAnnotation.Clickable("hapi-file:$path", ctx.linkStyles, listener)) { + if (monospace) { + withStyle(ctx.codeSpanStyle.copy(color = ctx.colors.link)) { append(display) } + } else { + append(display) + } + } +} + +/** Inline code: whole-value file paths become links, everything else is a code span. */ +private fun AnnotatedString.Builder.appendInlineCode(literal: String, ctx: InlineContext, insideLink: Boolean) { + val whole = if (insideLink) null else MarkdownTransforms.matchWholeFilePath(literal) + if (whole != null) { + appendFilePathLink(whole.display, whole.path, whole.line, ctx, monospace = true) + } else { + withStyle(ctx.codeSpanStyle) { append(literal) } + } +} + +private fun AnnotatedString.Builder.appendLink(node: Link, ctx: InlineContext) { + val destination = node.destination.orEmpty() + + // Autolinked URLs (text == destination) may have swallowed trailing CJK + // punctuation; split it back out as plain text (web parity). + val onlyChild = node.firstChild + val isAutolink = onlyChild is MdText && onlyChild.next == null && onlyChild.literal == destination + if (isAutolink) { + val split = MarkdownTransforms.stripCjkAutolinkArtifacts(destination) + appendClassifiedLink(split.url, ctx) { append(split.url) } + if (split.trailing.isNotEmpty()) append(split.trailing) + return + } + + // Explicit [label](relative/file.ext) → session file viewer. + val filePath = MarkdownTransforms.rewriteExplicitLinkTarget(destination) + if (filePath != null) { + val listener = LinkInteractionListener { ctx.handler.onFilePath(filePath, null) } + withLink(LinkAnnotation.Clickable("hapi-file:$filePath", ctx.linkStyles, listener)) { + appendInlineChildren(node, ctx, insideLink = true) + } + return + } + + appendClassifiedLink(destination, ctx) { appendInlineChildren(node, ctx, insideLink = true) } +} + +/** Apply [HrefPolicy]: blocked → inert hint text; otherwise clickable via the handler. */ +private fun AnnotatedString.Builder.appendClassifiedLink( + url: String, + ctx: InlineContext, + content: AnnotatedString.Builder.() -> Unit, +) { + when (val decision = HrefPolicy.classify(url)) { + is HrefDecision.Blocked -> withStyle(SpanStyle(color = ctx.colors.hint)) { content() } + else -> { + val listener = LinkInteractionListener { ctx.handler.onUrl(url, decision) } + withLink(LinkAnnotation.Clickable("url:$url", ctx.linkStyles, listener)) { content() } + } + } +} + +/** No inline image loading in the markdown pass (M2 wires Coil): alt text as a link. */ +private fun AnnotatedString.Builder.appendImageFallback(node: Image, ctx: InlineContext) { + val destination = node.destination.orEmpty() + appendClassifiedLink(destination, ctx) { + if (node.firstChild != null) appendInlineChildren(node, ctx, insideLink = true) else append(destination) + } +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/ui/markdown/MarkdownPreviews.kt b/android/app/src/main/kotlin/app/hapi/companion/ui/markdown/MarkdownPreviews.kt new file mode 100644 index 0000000000..aa0d2f9da9 --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/ui/markdown/MarkdownPreviews.kt @@ -0,0 +1,144 @@ +package app.hapi.companion.ui.markdown + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import app.hapi.companion.ui.components.DiffView +import app.hapi.companion.ui.theme.HapiTheme +import app.hapi.protocol.git.UnifiedDiffParser + +/** + * Kitchen-sink markdown exercising every renderer path (headings, emphasis, + * strikethrough, inline code + file citations, links incl. blocked/custom + * schemes, CJK autolink artifacts, lists + task lists, blockquote, tables -- + * one streaming-truncated, fenced code, mermaid degradation, html fallback, + * thematic break). Compile-checked previews double as visual regressions. + */ +class MarkdownPreviewParameterProvider : PreviewParameterProvider { + override val values: Sequence = sequenceOf(KITCHEN_SINK_MARKDOWN) +} + +// A ~ in a raw string: kept literal; ${'$'} escapes are not needed below. +val KITCHEN_SINK_MARKDOWN: String = """ +# Release notes + +Shipped the **markdown pipeline** with *italics*, ~~strikeouts~~, and `inline code`. +Edit `web/src/lib/remark-repair-tables.ts` or jump to hub/src/startHub.ts:345 directly. + +Links: [docs](https://example.com/docs), [guide](docs/guide.md#install), +[blocked](/settings), vscode://file/readme.md, and https://example.com/a,中文标点。 + +## Checklist + +- [x] port transforms +- [ ] wire chat surface +- plain bullet + +1. first +2. second with `a/b.ts:12` + +> Quoted wisdom with **bold** and a [link](https://example.com). + +| Column A | Column B | Column C | +| --- | --- | +| left | 2 | `x|y` | + +```kotlin +fun greet(name: String): String { + // returns a greeting + return "Hello, ${'$'}name!" +} +``` + +```mermaid +graph TD; A-->B; +``` + +
raw html falls back to monospace
+ +--- + +Done. See CHANGELOG.md. +""".trimIndent() + +private val SAMPLE_DIFF = """ +diff --git a/src/app.ts b/src/app.ts +index 83db48f..bf269f4 100644 +--- a/src/app.ts ++++ b/src/app.ts +@@ -1,4 +1,5 @@ function main() + import fs from 'fs' +-const a = 1 ++const a = 2 ++const b = 3 + console.log(a) + export {} +@@ -10,2 +11,2 @@ + tail1 +-tail2 ++tail2! +""".trimIndent() + +@Preview(name = "Markdown light", showBackground = true, heightDp = 1400) +@Composable +private fun MarkdownPreviewLight( + @PreviewParameter(MarkdownPreviewParameterProvider::class) text: String, +) { + HapiTheme(darkTheme = false, dynamicColor = false) { + Surface { + Markdown( + text = text, + modifier = Modifier + .verticalScroll(rememberScrollState()) + .padding(16.dp), + ) + } + } +} + +@Preview(name = "Markdown dark", showBackground = true, backgroundColor = 0xFF1C1C1E, heightDp = 1400) +@Composable +private fun MarkdownPreviewDark( + @PreviewParameter(MarkdownPreviewParameterProvider::class) text: String, +) { + HapiTheme(darkTheme = true, dynamicColor = false) { + Surface { + Markdown( + text = text, + modifier = Modifier + .verticalScroll(rememberScrollState()) + .padding(16.dp), + ) + } + } +} + +@Preview(name = "Diff + code OLED", showBackground = true, backgroundColor = 0xFF000000) +@Composable +private fun DiffAndCodeOledPreview() { + HapiTheme(darkTheme = true, dynamicColor = false, oled = true) { + Surface { + val file = remember { UnifiedDiffParser.parse(SAMPLE_DIFF).first() } + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + DiffView(file = file, compact = true, compactLineLimit = 8) + CodeBlock( + code = "fun sum(a: Int, b: Int) = a + b", + language = "kotlin", + ) + } + } + } +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/ui/theme/HapiColors.kt b/android/app/src/main/kotlin/app/hapi/companion/ui/theme/HapiColors.kt new file mode 100644 index 0000000000..0c9efaed63 --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/ui/theme/HapiColors.kt @@ -0,0 +1,118 @@ +package app.hapi.companion.ui.theme + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color + +/** + * Semantic color tokens that Material3's scheme does not cover: markdown, + * code, and diff surfaces. Values mirror the web client's CSS custom + * properties (`web/src/index.css`) for its light / dark / OLED themes so both + * clients read the same. + */ +@Immutable +data class HapiExtendedColors( + /** True for the dark and OLED sets -- drives syntax highlight themes. */ + val isDark: Boolean, + val codeBackground: Color, + val codeHeaderBackground: Color, + val codeHeaderForeground: Color, + val inlineCodeBackground: Color, + val inlineCodeForeground: Color, + val blockquoteBackground: Color, + val blockquoteBar: Color, + val blockquoteForeground: Color, + val tableBackground: Color, + val tableHeaderBackground: Color, + val divider: Color, + val diffAddBackground: Color, + val diffAddText: Color, + val diffRemoveBackground: Color, + val diffRemoveText: Color, + val diffHunkBackground: Color, + val link: Color, + val hint: Color, +) + +/** Mirrors `:root` (light) tokens in web/src/index.css. */ +val HapiLightExtendedColors = HapiExtendedColors( + isDark = false, + codeBackground = Color(0xFFF5F6F7), + codeHeaderBackground = Color(0xFFECEFF2), + codeHeaderForeground = Color(0xFF717784), + inlineCodeBackground = Color(0xFFEBECEF), + inlineCodeForeground = Color(0xFF2D333B), + blockquoteBackground = Color(0xFFF1F3F5), + blockquoteBar = Color(0xFFC9D0D8), + blockquoteForeground = Color(0xFF525965), + tableBackground = Color(0xFFF5F6F7), + tableHeaderBackground = Color(0xFFECEFF2), + divider = Color(0xFFE2E5E9), + diffAddBackground = Color(0xFFE6FFED), + diffAddText = Color(0xFF24292E), + diffRemoveBackground = Color(0xFFFFEEF0), + diffRemoveText = Color(0xFF24292E), + diffHunkBackground = Color(0xFFECEFF2), + link = Color(0xFF111827), + hint = Color(0xFF6B7280), +) + +/** Mirrors `[data-theme="dark"]` tokens in web/src/index.css. */ +val HapiDarkExtendedColors = HapiExtendedColors( + isDark = true, + codeBackground = Color(0xFF2A2F35), + codeHeaderBackground = Color(0xFF353B43), + codeHeaderForeground = Color(0xFFC4CBD6), + inlineCodeBackground = Color(0xFF383E47), + inlineCodeForeground = Color(0xFFF5F7FA), + blockquoteBackground = Color(0xFF31363D), + blockquoteBar = Color(0xFF6B7481), + blockquoteForeground = Color(0xFFD7DDE6), + tableBackground = Color(0xFF2A2F35), + tableHeaderBackground = Color(0xFF353B43), + divider = Color(0xFF3A3F45), + diffAddBackground = Color(0xFF0D2E1F), + diffAddText = Color(0xFFC9D1D9), + diffRemoveBackground = Color(0xFF3F1B23), + diffRemoveText = Color(0xFFC9D1D9), + diffHunkBackground = Color(0xFF353B43), + link = Color(0xFFFFFFFF), + hint = Color(0xFF8E8E93), +) + +/** + * Mirrors `[data-theme="oled"]` tokens: pure-black canvas, elevation from + * borders instead of gray fills. + */ +val HapiOledExtendedColors = HapiExtendedColors( + isDark = true, + codeBackground = Color(0xFF0E0E10), + codeHeaderBackground = Color(0xFF161618), + codeHeaderForeground = Color(0xFFC4CBD6), + inlineCodeBackground = Color(0xFF1A1A1D), + inlineCodeForeground = Color(0xFFF5F7FA), + blockquoteBackground = Color(0xFF131316), + blockquoteBar = Color(0xFF3A3A40), + blockquoteForeground = Color(0xFFD7DDE6), + tableBackground = Color(0xFF0E0E10), + tableHeaderBackground = Color(0xFF161618), + divider = Color(0xFF26262A), + diffAddBackground = Color(0xFF07251A), + diffAddText = Color(0xFFC9D1D9), + diffRemoveBackground = Color(0xFF2C1217), + diffRemoveText = Color(0xFFC9D1D9), + diffHunkBackground = Color(0xFF161618), + link = Color(0xFF4EA1FF), + hint = Color(0xFF8E8E93), +) + +val LocalHapiExtendedColors = staticCompositionLocalOf { HapiLightExtendedColors } + +/** `MaterialTheme.hapi.codeBackground` etc. -- provided by [HapiTheme]. */ +val MaterialTheme.hapi: HapiExtendedColors + @Composable + @ReadOnlyComposable + get() = LocalHapiExtendedColors.current diff --git a/android/app/src/main/kotlin/app/hapi/companion/ui/theme/Theme.kt b/android/app/src/main/kotlin/app/hapi/companion/ui/theme/Theme.kt new file mode 100644 index 0000000000..aacc67a805 --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/ui/theme/Theme.kt @@ -0,0 +1,92 @@ +package app.hapi.companion.ui.theme + +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext + +private val LightColorScheme = lightColorScheme( + primary = Color(0xFF3D6837), + secondary = Color(0xFF54634D), + tertiary = Color(0xFF386569), +) + +private val DarkColorScheme = darkColorScheme( + primary = Color(0xFFA3D397), + secondary = Color(0xFFBCCBB2), + tertiary = Color(0xFFA0CFD2), +) + +/** + * Pure-black scheme for OLED panels (mirrors the web `[data-theme="oled"]` + * token set): the canvas is #000000 and containers stay near-black so + * elevation comes from hairlines, not gray fills. Never combined with + * dynamic color -- Material You surfaces would defeat the point. + */ +private val OledColorScheme = darkColorScheme( + primary = Color(0xFF4EA1FF), + onPrimary = Color(0xFF000000), + secondary = Color(0xFFBCCBB2), + tertiary = Color(0xFFA0CFD2), + background = Color(0xFF000000), + onBackground = Color(0xFFF5F5F7), + surface = Color(0xFF000000), + onSurface = Color(0xFFF5F5F7), + surfaceVariant = Color(0xFF131316), + onSurfaceVariant = Color(0xFFC4CBD6), + surfaceContainerLowest = Color(0xFF000000), + surfaceContainerLow = Color(0xFF0A0A0C), + surfaceContainer = Color(0xFF0E0E10), + surfaceContainerHigh = Color(0xFF131316), + surfaceContainerHighest = Color(0xFF161618), + outline = Color(0xFF3A3A40), + outlineVariant = Color(0xFF26262A), +) + +/** + * Material3 theme for the HAPI companion app. + * + * Dynamic color (Material You) on Android 12+, static fallback schemes below. + * Also provides [LocalHapiExtendedColors] (markdown/code/diff semantic tokens, + * see [HapiExtendedColors]) mirroring the web light/dark/OLED token sets. + * + * [oled] selects the pure-black variant (implies dark, disables dynamic + * color); the settings screen wires it up later -- callers default to the + * regular schemes. + */ +@Composable +fun HapiTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + dynamicColor: Boolean = true, + oled: Boolean = false, + content: @Composable () -> Unit, +) { + val colorScheme = when { + oled -> OledColorScheme + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + val extendedColors = when { + oled -> HapiOledExtendedColors + darkTheme -> HapiDarkExtendedColors + else -> HapiLightExtendedColors + } + + CompositionLocalProvider(LocalHapiExtendedColors provides extendedColors) { + MaterialTheme( + colorScheme = colorScheme, + content = content, + ) + } +} diff --git a/android/app/src/main/res/drawable/ic_agent_agy.xml b/android/app/src/main/res/drawable/ic_agent_agy.xml new file mode 100644 index 0000000000..75a9f8a86b --- /dev/null +++ b/android/app/src/main/res/drawable/ic_agent_agy.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_agent_claude.xml b/android/app/src/main/res/drawable/ic_agent_claude.xml new file mode 100644 index 0000000000..7e98b9b2ec --- /dev/null +++ b/android/app/src/main/res/drawable/ic_agent_claude.xml @@ -0,0 +1,14 @@ + + + + + diff --git a/android/app/src/main/res/drawable/ic_agent_codex.xml b/android/app/src/main/res/drawable/ic_agent_codex.xml new file mode 100644 index 0000000000..c19cd1f3e8 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_agent_codex.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_agent_copilot.xml b/android/app/src/main/res/drawable/ic_agent_copilot.xml new file mode 100644 index 0000000000..e1e307b898 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_agent_copilot.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/android/app/src/main/res/drawable/ic_agent_cursor.xml b/android/app/src/main/res/drawable/ic_agent_cursor.xml new file mode 100644 index 0000000000..fdb6f1c439 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_agent_cursor.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/android/app/src/main/res/drawable/ic_agent_gemini.xml b/android/app/src/main/res/drawable/ic_agent_gemini.xml new file mode 100644 index 0000000000..5cb935881e --- /dev/null +++ b/android/app/src/main/res/drawable/ic_agent_gemini.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_agent_grok.xml b/android/app/src/main/res/drawable/ic_agent_grok.xml new file mode 100644 index 0000000000..ec0c6ef7d7 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_agent_grok.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/android/app/src/main/res/drawable/ic_agent_kimi.xml b/android/app/src/main/res/drawable/ic_agent_kimi.xml new file mode 100644 index 0000000000..ec46262992 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_agent_kimi.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/ic_agent_opencode.xml b/android/app/src/main/res/drawable/ic_agent_opencode.xml new file mode 100644 index 0000000000..91977b1c68 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_agent_opencode.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/android/app/src/main/res/drawable/ic_agent_pi.xml b/android/app/src/main/res/drawable/ic_agent_pi.xml new file mode 100644 index 0000000000..fe86096f54 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_agent_pi.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/android/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000000..9667d38dac --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_launcher_monochrome.xml b/android/app/src/main/res/drawable/ic_launcher_monochrome.xml new file mode 100644 index 0000000000..3017fc097a --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_monochrome.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_stat_hapi.xml b/android/app/src/main/res/drawable/ic_stat_hapi.xml new file mode 100644 index 0000000000..5fb87ce27b --- /dev/null +++ b/android/app/src/main/res/drawable/ic_stat_hapi.xml @@ -0,0 +1,30 @@ + + + + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000000..7dbb3f6442 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000000..7dbb3f6442 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v33/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v33/ic_launcher.xml new file mode 100644 index 0000000000..b93a277704 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v33/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v33/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi-v33/ic_launcher_round.xml new file mode 100644 index 0000000000..b93a277704 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v33/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000..e67e44c870 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000000..d2be997258 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000..5c14f03d6e Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000000..0210237e0b Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000000..de1727c260 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000000..342aa96159 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000..6bd4b36954 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000000..ac64784238 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000..23c3d8f3e0 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000000..c83fab6d70 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/values-zh-rCN/strings.xml b/android/app/src/main/res/values-zh-rCN/strings.xml new file mode 100644 index 0000000000..926edf7b76 --- /dev/null +++ b/android/app/src/main/res/values-zh-rCN/strings.xml @@ -0,0 +1,517 @@ + + + + HAPI + + + 复制 + 已复制 + 文本 + 二进制文件不显示 + 显示另外 %1$d 行 + 收起 + + + 与您的 hub 配对 + HAPI 是自托管的:您的编码智能体运行在您自己的机器上,由您自己运营的 HAPI hub 管理。将本应用与该 hub 配对,即可在手机上查看并操控它们。 + 扫描 hub 启动时打印的“伴侣应用(Companion app)”二维码(需 --relay),或手动输入 hub 地址和访问令牌。 + 扫描二维码 + 手动输入 + 忽略 + 忽略 + 正在检查 hub… + 与此 hub 配对? + 此设备已与该 hub 配对。 + 立即配对 + 使用现有配对 + 用新配对码重新配对 + 取消 + 该配对链接无效或不完整。 + 返回 + + + 手动配对 + 这两项信息可在 hub 启动时的终端里找到,也可在网页应用的“设置 → 伴侣应用配对”中查看。 + Hub 地址 + http://192.168.1.10:3006 + 访问令牌 + 粘贴访问令牌 + 配对 + + + 扫描配对二维码 + 将相机对准配对二维码 + 这不是 HAPI 配对二维码。请扫描 hub 打印的两个二维码之一(伴侣应用或网页应用)。 + 重新扫描 + 扫描配对二维码需要相机权限。您也可以改用手动配对。 + 授予相机权限 + + + 菜单 + 设置 + 切换 hub + 配对另一个 hub… + 退出登录 + 移除此 hub 配对?存储在本设备上的凭据将被删除(hub 本身不受影响)。 + 取消 + + + 新建会话 + 返回 + 新建会话 + 机器 + 加载机器… + 没有在线机器 + Runner 上次启动错误:%1$s + 目录 + /path/to/project + 最近路径 + 会话类型 + 简单 + 直接使用选定的目录 + 工作树 + 在仓库旁创建新的 git 工作树 + 工作树名称 + feature-x(可选) + 代理 + 模型 + 思考强度 + 推理强度 + 权限模式 + 由代理管理 + 此代理自行管理权限,YOLO 模式不适用。 + YOLO 模式 + 跳过审批和沙箱 + 启动时使用危险的代理标志。 + 将应用原生的 %1$s 模式。 + 协作模式 + 代理模式 + 快速模式 + 标准 + 快速 + 加载中… + 创建 + 创建中… + 创建并新建目录 + + + 设置 + 返回 + 取消 + 重试 + 无法加载此看板。 + 此看板仅对 hub 所有者(default 命名空间)可用。 + + 外观 + 主题 + 跟随系统 + 浅色 + 深色 + OLED 纯黑 + Material You 配色 + 根据壁纸生成应用配色。 + 在 OLED 纯黑下不可用。 + + 语言 + 应用语言 + 跟随系统 + + 洞察 + 各代理与模型的 Token 用量 + Hub 数据库磁盘占用 + + 关于 + 应用版本 + 协议版本 + Hub + 正在检查 hub… + 状态:%1$s · 协议 v%2$d + 无法连接 hub — 点按重试。 + + + Token 用量 + 近 7 天 + 近 30 天 + 全部时间 + 总 Token + 非缓存 + 输入 + 输出 + 缓存命中 + 缓存创建 + 缓存命中率 + 请求数 + 每日 Token + %1$s Token · %2$d 次请求 · 输入 %3$s / 输出 %4$s + 0 Token + 按 Agent + 按模型 + %1$d 次请求 · 输入 %2$s · 输出 %3$s + 有用量记录的会话:%1$d + 此时间段内没有记录到用量。 + + + 存储空间 + Hub SQLite 数据库的磁盘占用(数据库、预写日志、共享内存)。 + SQLite 文件 + 暂无存储数据。 + 数据库 + 预写日志 + 共享内存 + 总计 + 路径 + 刷新 + 正在刷新… + + + 权限请求 + 智能体正在等待您允许或拒绝一次工具调用 + 代理就绪 + 智能体已完成并正在等待输入 + 任务通知 + 任务完成或失败等更新 + 允许 + 拒绝 + 回复 + 忽略 + 消息 + 允许中… + 拒绝中… + 发送中… + 已允许 + 已拒绝 + 回复已发送 + 已处理 + 会话已停止 — 请打开应用以恢复 + 在已配对的 hub 上未找到该会话 + 无法连接 hub — 请打开应用处理 + 回复未发送 — 请打开应用重试 + + + 置顶会话 + 会话 + 全部 + 未知机器 + 离线 — 显示缓存的会话 + 正在加载会话… + 无法连接 hub + 还没有会话 + 正在从 hub 获取会话列表。 + 恢复网络后下拉重试。 + 用 hapi CLI 启动一个智能体,会话就会出现在这里。 + 需要输入 + 批准 %1$s + 待处理 + 取消置顶 + 项目置顶 + 全局置顶 + 重命名 + 重新打开 + 归档 + 删除 + 置顶操作失败 + 归档失败 + 重命名失败 + 删除失败 + 重新打开失败 + 无法加载机器列表 + 会话仍在运行 — 请先归档 + 无法重新打开会话 + 重命名会话 + 会话名称 + 删除会话? + “%1$s”及其消息历史将从 hub 永久移除。运行中的会话必须先归档。 + 取消 + 刚刚 + + + 返回 + 会话文件 + 草稿夹 + 草稿夹(%1$d) + 会话设置 + 会话操作 + 草稿夹 + 会话已停止 — 发送消息即可恢复,或 + 重试 + 加载消息… + 正在加载更早的消息… + 无法加载此会话 + 请检查与 hub 的连接后重试。 + 还没有消息 + 智能体工作时,消息会显示在这里。 + 1 条新消息 ↓ + %1$d 条新消息 ↓ + + + Hub 未配置转录提供商 + 语音输入需要麦克风权限 + 拍照需要相机权限 + %1$s 超过 50 MB 上传上限 + 无法读取 %1$s + 草稿已存入草稿夹 + 草稿夹已满(200 条) + 无法暂存草稿 — 请检查 hub 连接 + 附件仍在上传 — 请稍候,或重试/移除失败的附件 + 会话已停止,且无法恢复 + 中止失败 + 重命名会话失败 + 删除会话失败 + 取消队列消息失败 + 消息已取消 — 已保留当前草稿 + 已发送给智能体 + 插入消息失败 + 该请求已被处理 + 请求失败 + 加载模型失败 + 更新会话失败 + + + 输入消息… + 上传中… + 失败 — 点按重试 + ● 录音中… %1$s + 取消 + 将草稿存入草稿夹 + 添加附件 + 开始语音输入 + 停止录音 + 发送 + 中止当前回合 + 发送并插入当前回合 + 1 条队列消息 + %1$d 条队列消息 + 定时发送 · %1$s + 插入 + 编辑 + 添加附件 + 相册 + 相机 + 文件 + + + 无法开始录音 + 无法连接 hub + 录音失败 + 未录到任何音频 + 转录失败 + + + 会话已离线 — 更改将在恢复后生效,也可能被拒绝。 + 会话由终端控制 — 配置更改会被拒绝。 + 权限模式 + 模型 + 正在加载模型… + 此会话暂无模型列表。 + 思考强度 + + + 等待审批 + ⏳ 等待审批 + ✓ 已批准 + ✕ 已拒绝 + — 已取消 + 等待中 + 错误 + 1 个智能体步骤 + %1$d 个智能体步骤 + 1 个工具 + %1$d 个工具 + 修改前 + 修改后 + 编辑 %1$d/%2$d + (空) + 结果 + 结果 · 错误 + 💭 思考 ▾ + 💭 思考 ▸ + 未送达 — 点按重试 + 未送达 + Code review + 1 条发现 + %1$d 条发现 + + + 允许 + 拒绝 + 中止 + 本会话允许 + 允许所有编辑 + 已在其他设备处理 + 输入您的答案… + 请先输入答案 + 请先回答所有问题 + 请先回答所有必答问题 + 提交 + 其他… + %1$s(可选) + 添加备注… + + + 打开此链接? + 打开 + 没有应用可以打开此链接 + + + 终端 + 读取文件 + 读取笔记本 + 编辑文件 + 编辑笔记本 + 写入文件 + 搜索文件 + 搜索内容 + 列出文件 + 网页抓取 + 网页搜索 + 任务 + 启动智能体 + 智能体:%1$s + 智能体 + 派生智能体 + 恢复智能体 + 等待智能体 + 关闭智能体 + 中断智能体 + 广播 + 关闭:%1$s + 智能体 + 关闭响应 + 消息:%1$s + 向智能体发消息 + 列出智能体 + 团队:%1$s + 创建团队 + 删除团队 + 任务清单 + 计划 + 计划提案 + 技能 + 技能:%1$s + 思考 + 权限请求 + 权限:%1$s + 应用更改 + Diff + %1$s 日志 + 查看任务日志 + 后台任务 + 错误 + 运行命令 + 搜索 + 打开链接 + 查询 + 问题 + %1$d 个问题 + %1$s(另 %2$d 个) + %1$d 行 + %1$d 字符 + %1$s(%2$d 处编辑) + + + 返回 + 文件 + 刷新 + 变更 + 目录 + 搜索 + 此会话的 Git 状态不可用 + 游离 HEAD + %1$d 个已暂存 · %2$d 个未暂存 + 工作区没有变更 + 已暂存(%1$d) + 未暂存(%1$d) + 项目根目录 + 显示隐藏文件 + 收起 + 展开 + 搜索文件… + 输入以在会话目录中搜索文件 + 没有匹配的文件 + Git 状态不可用 + 未暂存 Diff 不可用:%1$s + 已暂存 Diff 不可用:%1$s + 未知错误 + 加载目录失败 + 搜索文件失败 + 加载 Diff 失败 + 读取文件失败 + 已复制 + 复制路径 + Diff + 文件 + 未暂存 + 已暂存 + 源码 + 预览 + 第 %1$d 行 + 此文件没有变更 + 文件为空 + 二进制文件 — 无法预览 + 此图片格式无法预览 + + + 返回 + 草稿夹 + 暂无笔记 + 1 条笔记 + %1$d 条笔记 + 新建笔记 + 编辑笔记 + 复制到输入框 + 仅附件 + 笔记、草稿或想法… + 添加图片 + 删除 + 取消 + 保存 + 重试 + 无法加载草稿夹 + 请检查与 hub 的连接后重试。 + 还没有笔记 + 从输入框暂存草稿,或点按 + 为此会话记一条笔记。 + 草稿夹已满(200 条)— 请先删除一条 + 草稿夹已满(200 条) + 笔记需要文字或附件 — 如需移除请使用“删除” + 无法保存笔记 — 请检查 hub 连接 + 无法删除笔记 — 请检查 hub 连接 + 无法附加文件 — 请检查 hub 连接 + 无法移除附件 — 请检查 hub 连接 + 该文件超过 hub 的单文件大小上限 + 上传失败 — 请检查 hub 连接 + 无法读取所选文件 + 图片压缩后仍然过大 + 一条笔记最多包含 %1$d 个附件 + 草稿夹附件不支持该文件类型 + 该文件超过 %1$d MB 上限 + 此笔记的附件容量已用完 + + + 请输入有效的 hub 地址,例如 http://192.168.1.10:3006 或 https://hub.example.com。 + 请输入 hub 显示的访问令牌。 + Hub 拒绝了此访问令牌。Hub 轮换 CLI_API_TOKEN 后令牌会变化 — 请重新获取二维码或令牌后再试。 + 该 hub 已不在配对列表中。请在下方重新配对。 + 无法连接 %1$s。请检查地址和网络,并确认 hub 正在运行。 + %1$s 有响应,但看起来不是 HAPI hub。请核对地址(应为 hub 自身的地址,而非网页应用的地址)。 + 此 hub 使用协议 v%1$d,而应用支持 v%2$d。请更新应用后重试。 + 此 hub 使用协议 v%1$d,而应用支持 v%2$d。请更新 hub 后重试。 + 配对失败(HTTP %1$d)。请稍后重试。 + 此设备的配对已被撤销 — hub 的访问令牌已更改。请重新配对以继续。 + Hub 持续拒绝此设备的会话。请重新配对以继续。 + 此 hub 的存储凭据已丢失。请重新配对以继续。 + + + 工作树会话需要已存在的仓库目录。 + 目录不存在,创建会话时将自动创建。 + 目录不存在。再次点按“创建”将自动新建该目录。 + 创建会话失败 + 加载 Codex 模型失败 + 加载模型失败:%1$s + 名称需要至少包含一个字母或数字 + diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000000..957a368b31 --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,5 @@ + + + #F25562 + #FFF8F8 + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000000..8000898697 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,510 @@ + + + HAPI + + + Copy + Copied + text + Binary file not shown + Show %1$d more lines + Collapse + + + Pair with your hub + HAPI is self-hosted: your coding agents run on your own machines behind a HAPI hub you operate. Pair this app with that hub to watch and steer them from your phone. + Scan the “Companion app” QR code your hub prints on startup (with --relay), or enter the hub URL and access token by hand. + Scan QR code + Enter manually + Dismiss + Dismiss + Checking hub… + Pair with this hub? + This hub is already paired on this device. + Pair now + Use existing pairing + Re-pair with the new code + Cancel + That pairing link is invalid or incomplete. + Back + + + Manual pairing + Find both values in the hub terminal on startup, or in the web app under Settings → Companion pairing. + Hub URL + http://192.168.1.10:3006 + Access token + Paste the access token + Pair + + + Scan pairing QR + Point the camera at the pairing QR code + That QR code is not a HAPI pairing code. Scan one of the two QR codes your hub prints (Companion app or Web app). + Scan again + Camera access is needed to scan the pairing QR code. You can also pair manually instead. + Grant camera access + + + Menu + Settings + Switch hub + Pair another hub… + Sign out + Remove this hub pairing? The stored credentials are deleted from this device (the hub itself is untouched). + Cancel + + + New session + Back + New session + Machine + Loading machines… + No machines online + Runner last spawn error: %1$s + Directory + /path/to/project + Recent paths + Session type + Simple + Use selected directory as-is + Worktree + Create a new git worktree next to the repo + Worktree name + feature-x (optional) + Agent + Model + Effort + Reasoning effort + Permission mode + Managed by agent + This agent manages its own permissions. YOLO mode is not available. + YOLO mode + Bypass approvals and sandbox + Uses dangerous agent flags when spawning. + Applies native %1$s mode. + Collaboration mode + Agent mode + Fast mode + Standard + Fast + Loading… + Create + Creating… + Create and make directory + + + Settings + Back + Cancel + Retry + Could not load this dashboard. + This dashboard is only available to the hub owner (default namespace). + + Appearance + Theme + Follow system + Light + Dark + OLED black + Material You colors + Derive app colors from your wallpaper. + Not available with OLED black. + + Language + App language + Follow system + + Insights + Token usage across agents and models + Hub database size on disk + + About + App version + Protocol version + Hub + Checking hub… + Status: %1$s · protocol v%2$d + Hub unreachable — tap to retry. + + + Usage + 7 days + 30 days + All time + Total tokens + Uncached + Input + Output + Cache read + Cache creation + Cache hit rate + Requests + Daily tokens + %1$s tokens · %2$d req · in %3$s / out %4$s + 0 tokens + By agent + By model + %1$d requests · in %2$s · out %3$s + Sessions with usage: %1$d + No usage recorded in this period. + + + Storage + Disk footprint of the hub\'s SQLite database (database, write-ahead log, shared memory). + SQLite files + No storage data reported. + Database + Write-ahead log + Shared memory + Total + Path + Refresh + Refreshing… + + + Permission requests + An agent is waiting for you to allow or deny a tool call + Agent ready + An agent finished and is waiting for input + Task notifications + Task completed or failed, and other updates + Allow + Deny + Reply + Dismiss + Message + Allowing… + Denying… + Sending… + Allowed + Denied + Reply sent + Already handled + Session is no longer active — open the app to resume it + Session not found on any paired hub + Couldn’t reach the hub — open the app to respond + Reply not sent — open the app to try again + + + Pinned + Sessions + All + Unknown machine + Offline — showing cached sessions + Loading sessions… + Hub unreachable + No sessions yet + Fetching the session list from the hub. + Pull to retry once you are back online. + Start an agent with the hapi CLI and it will appear here. + needs input + approve %1$s + pending + Unpin + Pin to project + Pin globally + Rename + Reopen + Archive + Delete + Pin failed + Archive failed + Rename failed + Delete failed + Reopen failed + Could not load machines + Session is still active — archive it first + Failed to reopen session + Rename session + Session name + Delete session? + “%1$s” and its message history will be removed from the hub permanently. Active sessions must be archived first. + Cancel + now + + + Back + Session files + Scratchlist + Scratchlist (%1$d) + Session settings + Session actions + Scratchlist + Session inactive — send to resume, or + Retry + Loading messages… + Loading older messages… + Couldn’t load this session + Check the connection to your hub and try again. + No messages yet + Messages will appear here as the agent works. + 1 new message ↓ + %1$d new messages ↓ + + + No transcription provider configured on hub + Microphone permission is needed for dictation + Camera permission is needed to take a photo + %1$s is over the 50 MB upload limit + Couldn’t read %1$s + Draft parked to scratchlist + Scratchlist is full (200 entries) + Couldn’t park the draft — check the hub connection + Attachments are still uploading — wait, or retry/remove the failed ones + Session is inactive and could not be resumed + Failed to abort + Failed to rename session + Failed to delete session + Failed to cancel queued message + Message cancelled — kept your current draft + Already delivered to the agent + Failed to steer message + Request was already handled + Request failed + Failed to load models + Failed to update session + + + Message the agent… + Uploading… + Failed — tap to retry + ● Recording… %1$s + Cancel + Park draft to scratchlist + Add attachment + Start dictation + Stop recording + Send + Stop the current turn + Send & steer into current turn + 1 queued message + %1$d queued messages + Scheduled · %1$s + Steer + Edit + Attach + Photo library + Camera + Files + + + Could not start recording + Could not reach the hub + Audio recording failed + No audio was recorded + Transcription failed + + + Session is offline — changes apply after it resumes or may be rejected. + Session is controlled from the terminal — config changes will be rejected. + Permission mode + Model + Loading models… + Model list unavailable for this session. + Effort + + + Awaiting approval + ⏳ Awaiting approval + ✓ Approved + ✕ Denied + — Canceled + pending + error + 1 agent step + %1$d agent steps + 1 tool + %1$d tools + Before + After + Edit %1$d/%2$d + (empty) + Result + Result · error + 💭 Reasoning ▾ + 💭 Reasoning ▸ + Not delivered — tap to retry + Not delivered + Code review + 1 finding + %1$d findings + + + Allow + Deny + Abort + Allow for this session + Allow all edits + Already handled elsewhere + Type your answer… + Type an answer first + Answer every question first + Answer every required question first + Submit + Other… + %1$s (optional) + Add a note… + + + Open link? + Open + No app can open this link + + + Terminal + Read file + Read notebook + Edit file + Edit notebook + Write file + Search files + Search content + List files + Web fetch + Web search + Task + Launch Agent + Agent: %1$s + Agent + Spawn agent + Resume agent + Wait for agent + Close agent + Interrupt agent + Broadcast + Shutdown: %1$s + agent + Shutdown Response + Message: %1$s + Message agent + List agents + Team: %1$s + Create Team + Delete Team + Todo list + Plan + Plan proposal + Skill + Skill: %1$s + Reasoning + Permission request + Permission: %1$s + Apply changes + Diff + %1$s log + Inspecting task log + Background task + Error + Run shell + Search + Open URL + Query + Question + %1$d Questions + %1$s (+%2$d more) + %1$d lines + %1$d chars + %1$s (%2$d edits) + + + Back + Files + Refresh + Changes + Browse + Search + Git status unavailable for this session + Detached HEAD + %1$d staged · %2$d unstaged + No changes in the working tree + Staged (%1$d) + Unstaged (%1$d) + Project root + Show hidden files + Collapse + Expand + Search files… + Type to search files in the session directory + No files matched + Git status unavailable + Unstaged diff unavailable: %1$s + Staged diff unavailable: %1$s + unknown error + Failed to list directory + Failed to search files + Failed to load diff + Failed to read file + Copied + Copy path + Diff + File + Unstaged + Staged + Source + Preview + Line %1$d + No changes in this file + This file is empty + Binary file — no preview available + Preview not available for this image format + + + Back + Scratchlist + No notes + 1 note + %1$d notes + New note + Edit note + To composer + Attachment only + Note, draft, parking-lot idea… + Add photo + Delete + Cancel + Save + Retry + Couldn’t load the scratchlist + Check the connection to your hub and try again. + No notes yet + Park drafts from the composer, or tap + to jot a note for this session. + Scratchlist is full (200 entries) — delete one first + Scratchlist is full (200 entries) + A note needs text or an attachment — use Delete to remove it + Couldn’t save the note — check the hub connection + Couldn’t delete the note — check the hub connection + Couldn’t attach the file — check the hub connection + Couldn’t remove the attachment — check the hub connection + That file is over the hub’s per-file size limit + Upload failed — check the hub connection + Couldn’t read the selected file + Image is too large even after compression + A note can hold at most %1$d attachments + That file type isn’t allowed for scratchlist attachments + That file is over the %1$d MB limit + This note’s attachment budget is used up + + + Enter a valid hub URL, like http://192.168.1.10:3006 or https://hub.example.com. + Enter the access token shown by your hub. + The hub rejected this access token. Tokens change when the hub rotates its CLI_API_TOKEN — grab a fresh QR code or token and try again. + That hub is no longer paired. Pair it again below. + Could not reach %1$s. Check the address, your network, and that the hub is running. + %1$s answered, but not like a HAPI hub. Double-check the URL (it should be the hub’s own address, not the web app’s). + This hub speaks protocol v%1$d but the app supports v%2$d. Update the app and try again. + This hub speaks protocol v%1$d but the app supports v%2$d. Update the hub and try again. + Pairing failed (HTTP %1$d). Try again in a moment. + This device’s pairing was revoked — the hub’s access token changed. Pair again to continue. + The hub kept rejecting this device’s session. Pair again to continue. + The stored credentials for this hub went missing. Pair again to continue. + + + Worktree sessions require an existing repository directory. + Directory does not exist. Creating the session will create it automatically. + Directory does not exist. Tap Create again to create it automatically. + Failed to create session + Failed to load Codex models + Failed to load models: %1$s + Name needs at least one letter or digit + diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000000..6094611954 --- /dev/null +++ b/android/app/src/main/res/values/themes.xml @@ -0,0 +1,8 @@ + + + +