diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3617f479..2a7b23e5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,23 @@ name: CI on: pull_request: - push: - branches: - - main + paths: + - ".github/workflows/*.yml" + - "apps/cloud/**" + - "apps/desktop/**" + - "apps/mesh-front-door/**" + - "packages/agent-sessions/**" + - "packages/cli/**" + - "packages/protocol/**" + - "packages/runtime/**" + - "packages/session-trace/**" + - "packages/session-trace-react/**" + - "packages/web/**" + - "scripts/**" + - "package.json" + - "bun.lock" + - "tsconfig*.json" + workflow_call: permissions: contents: read diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index cfad882a..6978fee5 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,8 +1,13 @@ -name: Deploy to GitHub Pages +name: Deploy Site on: push: branches: [main] + paths: + - ".github/workflows/deploy.yml" + - "landing/**" + - "package.json" + - "bun.lock" permissions: contents: read diff --git a/.github/workflows/release-app-ios.yml b/.github/workflows/release-app-ios.yml new file mode 100644 index 00000000..cfb3ab1f --- /dev/null +++ b/.github/workflows/release-app-ios.yml @@ -0,0 +1,63 @@ +name: Release App iOS + +on: + push: + tags: + - app-ios-v* + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: release-app-ios-${{ github.ref }} + cancel-in-progress: false + +jobs: + app-store: + name: Upload to App Store Connect + runs-on: macos-latest + timeout-minutes: 60 + environment: Production + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: "24.x" + package-manager-cache: false + + - name: Install XcodeGen + run: brew install xcodegen + + - name: Validate release target + shell: bash + run: | + set -euo pipefail + + package_version="$(node -p "require('./package.json').version")" + + if [[ "$GITHUB_REF_TYPE" == "tag" ]]; then + expected="${GITHUB_REF_NAME#app-ios-v}" + if [[ "$expected" != "$package_version" ]]; then + echo "::error::Tag version $expected does not match package.json version $package_version." + exit 1 + fi + fi + + - name: Verify asc CLI + env: + OPENSCOUT_ASC_BIN: ${{ vars.OPENSCOUT_ASC_BIN || 'asc' }} + run: | + set -euo pipefail + + if ! command -v "$OPENSCOUT_ASC_BIN" >/dev/null 2>&1; then + echo "::error::Missing App Store Connect CLI: $OPENSCOUT_ASC_BIN" + exit 1 + fi + + - name: Release to App Store Connect + env: + OPENSCOUT_ASC_APP_ID: ${{ vars.OPENSCOUT_ASC_APP_ID || '6761672978' }} + OPENSCOUT_ASC_BIN: ${{ vars.OPENSCOUT_ASC_BIN || 'asc' }} + run: bash apps/ios/scripts/release.sh diff --git a/.github/workflows/release-app-macos.yml b/.github/workflows/release-app-macos.yml new file mode 100644 index 00000000..1a80828a --- /dev/null +++ b/.github/workflows/release-app-macos.yml @@ -0,0 +1,137 @@ +name: Release App macOS + +on: + push: + tags: + - app-macos-v* + workflow_dispatch: + inputs: + version: + description: "Version to release. Defaults to package.json." + required: false + upload_release: + description: "Create or update the GitHub release with the DMG." + type: boolean + required: false + default: true + +permissions: + contents: write + +concurrency: + group: release-app-macos-${{ github.event_name == 'workflow_dispatch' && inputs.version || github.ref_name || github.run_id }} + cancel-in-progress: false + +jobs: + dmg: + name: Build signed and notarized DMG + runs-on: macos-latest + timeout-minutes: 45 + environment: Production + steps: + - uses: actions/checkout@v6 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3.13" + + - name: Resolve release target + id: release + shell: bash + env: + INPUT_VERSION: ${{ inputs.version || '' }} + run: | + set -euo pipefail + + if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]]; then + version="${INPUT_VERSION:-$(node -p "require('./package.json').version")}" + tag="app-macos-v${version}" + else + tag="$GITHUB_REF_NAME" + version="${tag#app-macos-v}" + fi + + if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]]; then + echo "::error::Release version must look like X.Y.Z, got: $version" + exit 1 + fi + + echo "tag=$tag" >> "$GITHUB_OUTPUT" + echo "version=$version" >> "$GITHUB_OUTPUT" + + - name: Import Developer ID certificate + shell: bash + env: + P12_BASE64: ${{ secrets.MACOS_DEVELOPER_ID_APPLICATION_P12_BASE64 }} + P12_PASSWORD: ${{ secrets.MACOS_DEVELOPER_ID_APPLICATION_P12_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ secrets.MACOS_RELEASE_KEYCHAIN_PASSWORD }} + run: | + set -euo pipefail + + if [[ -z "$P12_BASE64" || -z "$P12_PASSWORD" || -z "$KEYCHAIN_PASSWORD" ]]; then + echo "::error::Missing macOS signing secrets." + exit 1 + fi + + keychain="$RUNNER_TEMP/openscout-release.keychain-db" + cert="$RUNNER_TEMP/developer-id-application.p12" + + printf '%s' "$P12_BASE64" | base64 -d > "$cert" + security create-keychain -p "$KEYCHAIN_PASSWORD" "$keychain" + security set-keychain-settings -lut 21600 "$keychain" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$keychain" + security import "$cert" -k "$keychain" -P "$P12_PASSWORD" -T /usr/bin/codesign -T /usr/bin/security + security list-keychains -d user -s "$keychain" "$HOME/Library/Keychains/login.keychain-db" + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$keychain" + + - name: Configure notarization profile + shell: bash + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + run: | + set -euo pipefail + + if [[ -z "$APPLE_ID" || -z "$APPLE_TEAM_ID" || -z "$APPLE_APP_SPECIFIC_PASSWORD" ]]; then + echo "::error::Missing Apple notarization secrets." + exit 1 + fi + + xcrun notarytool store-credentials openscout-ci-notarytool \ + --apple-id "$APPLE_ID" \ + --team-id "$APPLE_TEAM_ID" \ + --password "$APPLE_APP_SPECIFIC_PASSWORD" + + - name: Build DMG + env: + VERSION: ${{ steps.release.outputs.version }} + OPENSCOUT_NOTARY_PROFILE: openscout-ci-notarytool + OPENSCOUT_SIGN_IDENTITY: ${{ vars.OPENSCOUT_SIGN_IDENTITY || '' }} + run: bash apps/macos/scripts/build-dmg.sh + + - name: Upload DMG artifact + uses: actions/upload-artifact@v5 + with: + name: OpenScoutMenu-${{ steps.release.outputs.version }} + path: apps/macos/dist/OpenScoutMenu-${{ steps.release.outputs.version }}.dmg + if-no-files-found: error + + - name: Create or update GitHub release + if: ${{ github.event_name == 'push' || inputs.upload_release }} + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ steps.release.outputs.tag }} + RELEASE_VERSION: ${{ steps.release.outputs.version }} + run: | + set -euo pipefail + + dmg="apps/macos/dist/OpenScoutMenu-${RELEASE_VERSION}.dmg" + + if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then + gh release upload "$RELEASE_TAG" "$dmg" --clobber + else + gh release create "$RELEASE_TAG" "$dmg" \ + --title "OpenScout macOS ${RELEASE_VERSION}" \ + --notes "Signed and notarized macOS app release." + fi diff --git a/.github/workflows/heavy-ci.yml b/.github/workflows/release-candidate.yml similarity index 56% rename from .github/workflows/heavy-ci.yml rename to .github/workflows/release-candidate.yml index 1bbc2d48..8f343b4c 100644 --- a/.github/workflows/heavy-ci.yml +++ b/.github/workflows/release-candidate.yml @@ -1,167 +1,31 @@ -name: Heavy CI +name: Release Candidate on: - pull_request: - types: - - opened - - synchronize - - reopened - - ready_for_review - - labeled - - unlabeled + push: + tags: + - candidate-v* workflow_dispatch: inputs: - target: - description: Heavy lane to run. - type: choice - required: true - default: full - options: - - native - - web-build - - e2e - - full live_harness: - description: Run the live agent harness when the e2e lane is selected. + description: Run the live agent harness after the normal candidate lanes. type: boolean required: false default: false permissions: contents: read - pull-requests: read concurrency: - group: heavy-ci-${{ github.event.pull_request.number || github.ref }} + group: release-candidate-${{ github.ref }} cancel-in-progress: true jobs: - plan: - name: Plan heavy lanes - runs-on: ubuntu-latest - outputs: - native: ${{ steps.plan.outputs.native }} - web_build: ${{ steps.plan.outputs.web_build }} - e2e: ${{ steps.plan.outputs.e2e }} - live_harness: ${{ steps.plan.outputs.live_harness }} - steps: - - name: Resolve labels, dispatch inputs, and touched paths - id: plan - env: - GH_TOKEN: ${{ github.token }} - DISPATCH_TARGET: ${{ github.event.inputs.target || '' }} - DISPATCH_LIVE_HARNESS: ${{ github.event.inputs.live_harness || 'false' }} - run: | - set -euo pipefail - - native=false - web_build=false - e2e=false - live_harness=false - reasons=() - - labels_file="$(mktemp)" - jq -r '.pull_request.labels[]?.name // empty' "$GITHUB_EVENT_PATH" > "$labels_file" - - has_label() { - grep -Fxq "$1" "$labels_file" - } - - if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]]; then - target="${DISPATCH_TARGET:-full}" - reasons+=("workflow_dispatch:${target}") - - case "$target" in - native) - native=true - ;; - web-build) - web_build=true - ;; - e2e) - e2e=true - ;; - full) - native=true - web_build=true - e2e=true - ;; - *) - echo "::error::Unsupported heavy CI target: $target" - exit 1 - ;; - esac - - if [[ "$DISPATCH_LIVE_HARNESS" == "true" ]]; then - live_harness=true - e2e=true - reasons+=("workflow_dispatch:live_harness") - fi - else - if has_label "ci:full"; then - native=true - web_build=true - e2e=true - reasons+=("label:ci:full") - fi - - if has_label "ci:native"; then - native=true - reasons+=("label:ci:native") - fi - - if has_label "ci:web-build"; then - web_build=true - reasons+=("label:ci:web-build") - fi - - if has_label "ci:e2e"; then - e2e=true - reasons+=("label:ci:e2e") - fi - - pr_number="$(jq -r '.pull_request.number // empty' "$GITHUB_EVENT_PATH")" - if [[ -n "$pr_number" ]]; then - files_file="$(mktemp)" - gh api --paginate "repos/${GITHUB_REPOSITORY}/pulls/${pr_number}/files" \ - --jq '.[].filename' > "$files_file" - - if grep -Eq '^(apps/ios|apps/macos|packages/scout-native-core)/' "$files_file"; then - native=true - reasons+=("paths:native") - fi - fi - fi - - if [[ "$e2e" != "true" ]]; then - live_harness=false - fi - - if ((${#reasons[@]} == 0)); then - reasons+=("no heavy lanes selected") - fi - - { - echo "native=$native" - echo "web_build=$web_build" - echo "e2e=$e2e" - echo "live_harness=$live_harness" - } >> "$GITHUB_OUTPUT" - - { - echo "### Heavy CI plan" - echo - echo "- native: \`$native\`" - echo "- web/CLI build: \`$web_build\`" - echo "- e2e: \`$e2e\`" - echo "- live harness: \`$live_harness\`" - echo "- reasons: \`${reasons[*]}\`" - } >> "$GITHUB_STEP_SUMMARY" + linux-checks: + name: Linux typecheck and unit tests + uses: ./.github/workflows/ci.yml native-macos: - name: Native macOS build/test - needs: plan - if: needs.plan.outputs.native == 'true' + name: macOS app build/test runs-on: macos-latest timeout-minutes: 30 steps: @@ -194,9 +58,7 @@ jobs: run: bun ./apps/macos/bin/openscout-menu.ts build native-ios: - name: Native iOS build/test - needs: plan - if: needs.plan.outputs.native == 'true' + name: iOS app build/test runs-on: macos-latest timeout-minutes: 60 steps: @@ -266,9 +128,7 @@ jobs: test web-cli-package-build: - name: Web and CLI packaged build - needs: plan - if: needs.plan.outputs.web_build == 'true' + name: Web and npm package build runs-on: ubuntu-latest timeout-minutes: 35 steps: @@ -298,8 +158,6 @@ jobs: e2e-scenarios: name: Runtime e2e scenarios - needs: plan - if: needs.plan.outputs.e2e == 'true' runs-on: ubuntu-latest timeout-minutes: 35 steps: @@ -320,8 +178,8 @@ jobs: live-harness: name: Live local harness pass - needs: plan - if: needs.plan.outputs.live_harness == 'true' + needs: e2e-scenarios + if: ${{ github.event_name == 'workflow_dispatch' && inputs.live_harness }} runs-on: ubuntu-latest timeout-minutes: 60 steps: diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/release-package-npm.yml similarity index 72% rename from .github/workflows/npm-publish.yml rename to .github/workflows/release-package-npm.yml index eafc236c..6edeccfe 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/release-package-npm.yml @@ -1,10 +1,13 @@ -name: Publish npm +name: Release Package npm on: + push: + tags: + - npm-v* workflow_dispatch: inputs: tag: - description: "Release tag to publish, for example v0.2.68" + description: "Release tag to publish, for example npm-v0.2.68 or v0.2.68" required: true npm_tag: description: "npm dist-tag" @@ -16,7 +19,7 @@ permissions: id-token: write concurrency: - group: npm-publish-${{ inputs.tag }} + group: release-package-npm-${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }} cancel-in-progress: false jobs: @@ -27,17 +30,36 @@ jobs: - name: Resolve release target id: release shell: bash + env: + INPUT_TAG: ${{ inputs.tag || '' }} + INPUT_NPM_TAG: ${{ inputs.npm_tag || '' }} run: | - tag="${{ inputs.tag }}" - npm_tag="${{ inputs.npm_tag }}" + set -euo pipefail + + if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]]; then + tag="$INPUT_TAG" + npm_tag="${INPUT_NPM_TAG:-latest}" + else + tag="$GITHUB_REF_NAME" + npm_tag="latest" + fi - if [[ ! "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+ ]]; then - echo "Release tag must look like vX.Y.Z, got: $tag" >&2 + case "$tag" in + npm-v*) version="${tag#npm-v}" ;; + v*) version="${tag#v}" ;; + *) + echo "Release tag must look like npm-vX.Y.Z or vX.Y.Z, got: $tag" >&2 + exit 1 + ;; + esac + + if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]]; then + echo "Release version must look like X.Y.Z, got: $version" >&2 exit 1 fi echo "tag=$tag" >> "$GITHUB_OUTPUT" - echo "version=${tag#v}" >> "$GITHUB_OUTPUT" + echo "version=$version" >> "$GITHUB_OUTPUT" echo "npm_tag=${npm_tag:-latest}" >> "$GITHUB_OUTPUT" - uses: actions/checkout@v6 diff --git a/apps/desktop/src/cli/commands/ask.test.ts b/apps/desktop/src/cli/commands/ask.test.ts index 60f8619c..3bbf5024 100644 --- a/apps/desktop/src/cli/commands/ask.test.ts +++ b/apps/desktop/src/cli/commands/ask.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { formatScoutAskRoutingError, renderAskCommandHelp } from "./ask.ts"; +import { formatScoutAskRoutingError, renderAskCommandHelp, renderScoutAskReceipt } from "./ask.ts"; describe("renderAskCommandHelp", () => { test("documents owned-work semantics and DM default routing", () => { @@ -92,3 +92,58 @@ describe("formatScoutAskRoutingError", () => { expect(message).toContain("Re-run with the fully qualified form"); }); }); + +describe("renderScoutAskReceipt", () => { + test("makes offline queued delivery explicit in notify mode", () => { + expect(renderScoutAskReceipt({ + replyMode: "notify", + receipt: { + ok: true, + state: "queued", + ids: { + targetAgentId: "talkie-shell-claude", + invocationId: "inv-1", + flightId: "flt-1", + conversationId: "dm.operator.talkie-shell-claude", + }, + }, + flight: { + id: "flt-1", + invocationId: "inv-1", + requesterId: "operator", + targetAgentId: "talkie-shell-claude", + state: "queued", + summary: "Message stored for Talkie Shell Claude. Will deliver when online.", + metadata: { + dispatchOutcome: { + status: "queued_until_online", + reason: "no_runnable_endpoint", + }, + }, + }, + })).toContain("Queued until target is online: Message stored for Talkie Shell Claude. Will deliver when online."); + }); + + test("calls out acknowledged dispatch separately from final completion", () => { + expect(renderScoutAskReceipt({ + replyMode: "notify", + receipt: { + ok: true, + state: "queued", + ids: { + targetAgentId: "openscout-card", + invocationId: "inv-2", + flightId: "flt-2", + }, + }, + flight: { + id: "flt-2", + invocationId: "inv-2", + requesterId: "operator", + targetAgentId: "openscout-card", + state: "running", + summary: "Openscout Card acknowledged via spawn.", + }, + })).toContain("Dispatch acknowledged: Openscout Card acknowledged via spawn."); + }); +}); diff --git a/apps/desktop/src/cli/commands/ask.ts b/apps/desktop/src/cli/commands/ask.ts index 31520173..e97c3152 100644 --- a/apps/desktop/src/cli/commands/ask.ts +++ b/apps/desktop/src/cli/commands/ask.ts @@ -22,6 +22,7 @@ import { const HELP_FLAGS = new Set(["--help", "-h"]); const DEFAULT_ASK_ACK_TIMEOUT_SECONDS = 30; +const DEFAULT_ASK_DISPATCH_SETTLE_MS = 4_000; export function renderAskCommandHelp(): string { return [ @@ -64,9 +65,10 @@ export function renderAskCommandHelp(): string { ].join("\n"); } -function renderScoutAskReceipt(value: { +export function renderScoutAskReceipt(value: { receipt: ScoutAskReceipt; replyMode: NonNullable; + flight?: ScoutFlightRecord | null; }): string { const { ids } = value.receipt; const pieces = [ @@ -75,14 +77,46 @@ function renderScoutAskReceipt(value: { ids.conversationId ? renderConversationRoute(ids.conversationId) : null, renderBindingRef(ids.bindingRef), ].filter((piece): piece is string => Boolean(piece)); + const delivery = renderScoutAskDeliveryStatus(value.flight); const suffix = value.replyMode === "notify" - ? "Scout will surface the completion when it arrives." - : ids.invocationId - ? `Next: scout wait ${ids.invocationId} --timeout 600` - : "Follow the ask receipt to continue."; + ? delivery + ? `${delivery} Scout will surface the completion when it arrives.` + : "Scout will surface the completion when it arrives." + : delivery + ? `${delivery} ${ids.invocationId ? `Next: scout wait ${ids.invocationId} --timeout 600` : "Follow the ask receipt to continue."}` + : ids.invocationId + ? `Next: scout wait ${ids.invocationId} --timeout 600` + : "Follow the ask receipt to continue."; return `${pieces.join(" · ")}. ${suffix}`; } +function renderScoutAskDeliveryStatus( + flight?: ScoutFlightRecord | null, +): string | null { + if (!flight) { + return null; + } + + const detail = flight.error ?? flight.summary ?? flight.output; + const renderedDetail = detail ? ` ${detail}` : ""; + if (flight.state === "running" || flight.state === "waiting") { + return `Dispatch acknowledged:${renderedDetail}`; + } + if (flight.state === "completed") { + return `Completed:${renderedDetail}`; + } + if (flight.state === "failed" || flight.state === "cancelled") { + return `Dispatch ${flight.state}:${renderedDetail}`; + } + if (flight.state === "queued" && isStoredUntilOnlineFlight(flight)) { + return `Queued until target is online:${renderedDetail}`; + } + if (flight.state === "queued" || flight.state === "waking") { + return `Dispatch ${flight.state}:${renderedDetail}`; + } + return `Dispatch state ${flight.state}:${renderedDetail}`; +} + function renderScoutTargetLabel(targetLabel: string): string { const trimmed = targetLabel.trim(); return trimmed.startsWith("@") ? trimmed : `@${trimmed}`; @@ -111,6 +145,47 @@ function waitReferenceForFlight(flight: ScoutFlightRecord): string { return flight.invocationId || flight.id; } +function isStoredUntilOnlineFlight(flight: ScoutFlightRecord): boolean { + const dispatchOutcome = flight.metadata?.dispatchOutcome; + if ( + dispatchOutcome + && typeof dispatchOutcome === "object" + && !Array.isArray(dispatchOutcome) + && (dispatchOutcome as { status?: unknown }).status === "queued_until_online" + ) { + return true; + } + return /will deliver when online|message stored/i.test(flight.summary ?? ""); +} + +function isSettledInitialDispatchFlight(flight: ScoutFlightRecord): boolean { + if (flight.state === "running" + || flight.state === "waiting" + || flight.state === "completed" + || flight.state === "failed" + || flight.state === "cancelled") { + return true; + } + return flight.state === "queued"; +} + +async function loadInitialScoutAskFlight( + brokerUrl: string, + flightId: string, + timeoutMs = DEFAULT_ASK_DISPATCH_SETTLE_MS, +): Promise { + const deadline = Date.now() + timeoutMs; + let latest: ScoutFlightRecord | null = null; + while (Date.now() < deadline) { + latest = await loadScoutFlight(brokerUrl, flightId); + if (!latest || isSettledInitialDispatchFlight(latest)) { + return latest; + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + return latest ?? await loadScoutFlight(brokerUrl, flightId); +} + function renderBindingRef(bindingRef?: string | null): string | null { if (!bindingRef) { return null; @@ -248,6 +323,10 @@ export async function runAskWithOptions( const replyMode = options.replyMode ?? "inline"; if (replyMode !== "inline") { + const flight = receipt.ids.flightId + ? await loadInitialScoutAskFlight(resolveScoutBrokerUrl(), receipt.ids.flightId) + .catch(() => null) + : null; context.output.writeValue( { senderId, @@ -256,6 +335,7 @@ export async function runAskWithOptions( bindingRef: renderBindingRef(receipt.ids.bindingRef), receipt, replyMode, + flight, }, renderScoutAskReceipt, ); diff --git a/apps/macos/Package.resolved b/apps/macos/Package.resolved new file mode 100644 index 00000000..55211d20 --- /dev/null +++ b/apps/macos/Package.resolved @@ -0,0 +1,42 @@ +{ + "originHash" : "1867f8f6c974a36df982033582ca1a62ad17776d0a86f52adf0cdc2a0a1af641", + "pins" : [ + { + "identity" : "hudson", + "kind" : "remoteSourceControl", + "location" : "git@github.com:arach/hudson.git", + "state" : { + "branch" : "main", + "revision" : "44c1c1017c07bd8f9417ba7493647c2e3701b105" + } + }, + { + "identity" : "swifttreesitter", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ChimeHQ/SwiftTreeSitter", + "state" : { + "revision" : "08ef81eb8620617b55b08868126707ad72bf754f", + "version" : "0.25.0" + } + }, + { + "identity" : "tree-sitter", + "kind" : "remoteSourceControl", + "location" : "https://github.com/tree-sitter/tree-sitter", + "state" : { + "revision" : "da6fe9beb4f7f67beb75914ca8e0d48ae48d6406", + "version" : "0.25.10" + } + }, + { + "identity" : "tree-sitter-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/alex-pinkus/tree-sitter-swift", + "state" : { + "branch" : "with-generated-files", + "revision" : "7b7909f2f6b9414be0958275f4c8e5d69c3bca43" + } + } + ], + "version" : 3 +} diff --git a/apps/macos/Package.swift b/apps/macos/Package.swift index 4f914d4b..fd679e7d 100644 --- a/apps/macos/Package.swift +++ b/apps/macos/Package.swift @@ -1,22 +1,46 @@ // swift-tools-version: 6.0 import PackageDescription +let hudsonSource = Context.environment["OPENSCOUT_HUDSON_SOURCE"] ?? "path" +let hudsonDependency: Package.Dependency = hudsonSource == "git" + ? .package(url: "git@github.com:arach/hudson.git", branch: "main") + : .package(path: "../../../hudson") + let package = Package( name: "OpenScoutMenu", platforms: [.macOS(.v14)], products: [ .executable(name: "OpenScoutMenu", targets: ["OpenScoutMenu"]), + .executable(name: "Scout", targets: ["Scout"]), ], dependencies: [ + hudsonDependency, .package(path: "../../packages/scout-native-core"), ], targets: [ .executableTarget( name: "OpenScoutMenu", + dependencies: [ + "ScoutSharedUI", + .product(name: "ScoutNativeCore", package: "scout-native-core"), + ], + path: "Sources/OpenScoutMenu" + ), + .target( + name: "ScoutSharedUI", dependencies: [ .product(name: "ScoutNativeCore", package: "scout-native-core"), ], - path: "Sources" + path: "Sources/ScoutSharedUI" + ), + .executableTarget( + name: "Scout", + dependencies: [ + "ScoutSharedUI", + .product(name: "HudsonShell", package: "hudson"), + .product(name: "HudsonUI", package: "hudson"), + ], + path: "Sources/Scout" ), ] ) diff --git a/apps/macos/ScoutInfo.plist b/apps/macos/ScoutInfo.plist new file mode 100644 index 00000000..e94cd6bf --- /dev/null +++ b/apps/macos/ScoutInfo.plist @@ -0,0 +1,31 @@ + + + + + CFBundleIdentifier + com.openscout.scout + CFBundleName + Scout + CFBundleDisplayName + Scout + CFBundleExecutable + Scout + CFBundlePackageType + APPL + CFBundleVersion + 1 + CFBundleShortVersionString + 0.1.1 + LSMinimumSystemVersion + 14.0 + NSHighResolutionCapable + + NSSupportsAutomaticTermination + + NSAppTransportSecurity + + NSAllowsLocalNetworking + + + + diff --git a/apps/macos/Sources/AppDelegate.swift b/apps/macos/Sources/OpenScoutMenu/AppDelegate.swift similarity index 91% rename from apps/macos/Sources/AppDelegate.swift rename to apps/macos/Sources/OpenScoutMenu/AppDelegate.swift index 7e8486e8..6d0bab40 100644 --- a/apps/macos/Sources/AppDelegate.swift +++ b/apps/macos/Sources/OpenScoutMenu/AppDelegate.swift @@ -45,6 +45,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { HUDController.shared.toggle() } } + HotkeyManager.shared.register( + id: 2, + keyCode: CarbonKeyCode.c, + modifiers: CarbonModifier.hyper + ) { + Task { @MainActor in + OpenScoutAppController.shared.openComms() + } + } controller.$menuBarSymbolName .combineLatest(controller.$menuBarTooltip) @@ -114,6 +123,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { private func buildContextMenu() -> NSMenu { let menu = NSMenu() + let commsItem = NSMenuItem(title: "Open Comms", action: #selector(openComms), keyEquivalent: "c") + commsItem.target = self + commsItem.keyEquivalentModifierMask = [.command, .control, .option, .shift] + menu.addItem(commsItem) + let openItem = NSMenuItem(title: "Open OpenScout", action: #selector(openWebApp), keyEquivalent: "") openItem.target = self menu.addItem(openItem) @@ -162,6 +176,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { controller.openWebApp() } + @objc + private func openComms() { + controller.openComms() + } + @objc private func openTailscale() { controller.openTailscale() diff --git a/apps/macos/Sources/HUD/HUDActivityView.swift b/apps/macos/Sources/OpenScoutMenu/HUD/HUDActivityView.swift similarity index 100% rename from apps/macos/Sources/HUD/HUDActivityView.swift rename to apps/macos/Sources/OpenScoutMenu/HUD/HUDActivityView.swift diff --git a/apps/macos/Sources/HUD/HUDAgentsView.swift b/apps/macos/Sources/OpenScoutMenu/HUD/HUDAgentsView.swift similarity index 100% rename from apps/macos/Sources/HUD/HUDAgentsView.swift rename to apps/macos/Sources/OpenScoutMenu/HUD/HUDAgentsView.swift diff --git a/apps/macos/Sources/HUD/HUDAssistantView.swift b/apps/macos/Sources/OpenScoutMenu/HUD/HUDAssistantView.swift similarity index 100% rename from apps/macos/Sources/HUD/HUDAssistantView.swift rename to apps/macos/Sources/OpenScoutMenu/HUD/HUDAssistantView.swift diff --git a/apps/macos/Sources/HUD/HUDCheatsheet.swift b/apps/macos/Sources/OpenScoutMenu/HUD/HUDCheatsheet.swift similarity index 100% rename from apps/macos/Sources/HUD/HUDCheatsheet.swift rename to apps/macos/Sources/OpenScoutMenu/HUD/HUDCheatsheet.swift diff --git a/apps/macos/Sources/HUD/HUDChrome.swift b/apps/macos/Sources/OpenScoutMenu/HUD/HUDChrome.swift similarity index 100% rename from apps/macos/Sources/HUD/HUDChrome.swift rename to apps/macos/Sources/OpenScoutMenu/HUD/HUDChrome.swift diff --git a/apps/macos/Sources/HUD/HUDController.swift b/apps/macos/Sources/OpenScoutMenu/HUD/HUDController.swift similarity index 99% rename from apps/macos/Sources/HUD/HUDController.swift rename to apps/macos/Sources/OpenScoutMenu/HUD/HUDController.swift index f604ced2..0eb7acff 100644 --- a/apps/macos/Sources/HUD/HUDController.swift +++ b/apps/macos/Sources/OpenScoutMenu/HUD/HUDController.swift @@ -1,5 +1,6 @@ import AppKit import Combine +import ScoutSharedUI import SwiftUI // Singleton controller for the OpenScout HUD overlay. diff --git a/apps/macos/Sources/HUD/HUDDockState.swift b/apps/macos/Sources/OpenScoutMenu/HUD/HUDDockState.swift similarity index 60% rename from apps/macos/Sources/HUD/HUDDockState.swift rename to apps/macos/Sources/OpenScoutMenu/HUD/HUDDockState.swift index adaba66d..e81c1019 100644 --- a/apps/macos/Sources/HUD/HUDDockState.swift +++ b/apps/macos/Sources/OpenScoutMenu/HUD/HUDDockState.swift @@ -2,55 +2,14 @@ import Combine import Foundation import os.log import ScoutNativeCore +import ScoutSharedUI import SwiftUI -enum HUDDockSuggestionKind: String { - case command - case agent - case session - - var eyebrow: String { - switch self { - case .command: return "COMMANDS" - case .agent: return "AGENTS" - case .session: return "SESSIONS" - } - } -} - -enum HUDDockSuggestionAction: String { - case openRunner -} - -struct HUDDockSuggestion: Identifiable, Equatable { - let id: String - let kind: HUDDockSuggestionKind - let label: String - let detail: String - let replacement: String - let targetHandle: String? - let targetLabel: String? - let action: HUDDockSuggestionAction? -} - -private struct HUDDockSuggestionTrigger: Equatable { - let kind: HUDDockSuggestionKind - let token: String - let query: String - let startOffset: Int - let endOffset: Int - - var signature: String { - "\(kind.rawValue):\(startOffset):\(token)" - } -} - -private struct HUDDockCommandCandidate { - let command: String - let detail: String - let replacement: String - let action: HUDDockSuggestionAction? -} +typealias HUDDockSuggestionKind = MessageSuggestionKind +typealias HUDDockSuggestionAction = MessageSuggestionAction +typealias HUDDockSuggestion = MessageSuggestion +private typealias HUDDockSuggestionTrigger = MessageSuggestionTrigger +private typealias HUDDockCommandCandidate = MessageCommandCandidate /// Owns the universal message dock's editable state — the text buffer, /// the routing target (an agent handle or nil = default channel), and @@ -95,6 +54,11 @@ final class HUDDockState: ObservableObject { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) self.log.info("vox final received — len=\(trimmed.count)") guard !trimmed.isEmpty else { return } + if CommsWindowController.shared.isPresented { + // Comms panel is foreground and owns dictation; its own + // subscription splices the transcript and drains it. + return + } if HUDRunnerState.shared.isPresented { HUDRunnerState.shared.appendDictatedText(trimmed) HudVoxService.shared.consumeFinalText() @@ -375,194 +339,32 @@ private extension HUDDockState { ] static func detectSuggestionTrigger(in value: String) -> HUDDockSuggestionTrigger? { - guard !value.isEmpty else { return nil } - let end = value.endIndex - var start = end - while start > value.startIndex { - let previous = value.index(before: start) - if value[previous].isWhitespace { - break - } - start = previous - } - - let token = String(value[start.. [HUDDockSuggestion] { - switch trigger.kind { - case .command: - return commandSuggestions(query: trigger.query) - case .agent: - return agentSuggestions(query: trigger.query, agents: agents) - case .session: - return sessionSuggestions(query: trigger.query, agents: agents) - } + MessageSuggestionEngine.suggestions( + for: trigger, + agents: agents.map(MessageSuggestionAgent.init), + commands: commandCandidates + ) } - static func commandSuggestions(query: String) -> [HUDDockSuggestion] { - let q = query.lowercased() - return commandCandidates - .filter { candidate in - q.isEmpty - || candidate.command.dropFirst().lowercased().hasPrefix(q) - || candidate.command.lowercased().contains(q) - || candidate.detail.lowercased().contains(q) - } - .prefix(8) - .map { candidate in - HUDDockSuggestion( - id: "command:\(candidate.command)", - kind: .command, - label: candidate.command, - detail: candidate.detail, - replacement: candidate.replacement, - targetHandle: nil, - targetLabel: nil, - action: candidate.action - ) - } - } - - static func agentSuggestions(query: String, agents: [HudAgent]) -> [HUDDockSuggestion] { - let q = query.lowercased() - var seen = Set() - return agents - .compactMap { agent -> HUDDockSuggestion? in - guard let handle = suggestionHandle(for: agent) else { return nil } - let key = handle.lowercased() - guard !seen.contains(key) else { return nil } - guard q.isEmpty - || handle.lowercased().contains(q) - || agent.name.lowercased().contains(q) - || agent.id.lowercased().contains(q) else { - return nil - } - seen.insert(key) - return HUDDockSuggestion( - id: "agent:\(key)", - kind: .agent, - label: "@\(handle)", - detail: agentSuggestionDetail(agent), - replacement: "", - targetHandle: handle, - targetLabel: agent.name, - action: nil - ) - } - .sorted { $0.label.localizedCaseInsensitiveCompare($1.label) == .orderedAscending } - .prefix(7) - .map { $0 } - } - - static func sessionSuggestions(query: String, agents: [HudAgent]) -> [HUDDockSuggestion] { - let q = query.lowercased() - var seen = Set() - return agents - .compactMap { agent -> HUDDockSuggestion? in - guard let sessionId = agent.harnessSessionId?.trimmingCharacters(in: .whitespacesAndNewlines), - !sessionId.isEmpty else { - return nil - } - let key = sessionId.lowercased() - guard !seen.contains(key) else { return nil } - guard q.isEmpty - || key.contains(q) - || agent.name.lowercased().contains(q) - || (agent.handle ?? "").lowercased().contains(q) else { - return nil - } - seen.insert(key) - return HUDDockSuggestion( - id: "session:\(key)", - kind: .session, - label: "sid:\(sessionId)", - detail: "\(agent.name) · \(agent.state.rawValue)", - replacement: "sid:\(sessionId) ", - targetHandle: nil, - targetLabel: nil, - action: nil - ) - } - .sorted { $0.label.localizedCaseInsensitiveCompare($1.label) == .orderedAscending } - .prefix(7) - .map { $0 } - } - - static func suggestionHandle(for agent: HudAgent) -> String? { - let raw = agent.handle ?? agent.name - let trimmed = raw - .trimmingCharacters(in: .whitespacesAndNewlines) - .trimmingCharacters(in: CharacterSet(charactersIn: "@")) - return trimmed.isEmpty ? nil : trimmed - } - - static func agentSuggestionDetail(_ agent: HudAgent) -> String { - let scope = (agent.projectRoot as NSString?)?.lastPathComponent ?? agent.role - return "\(agent.name) · \(agent.state.rawValue) · \(scope)" - } - - static func isSimpleQuery(_ value: String) -> Bool { - value.allSatisfy { ch in - ch.isLetter || ch.isNumber || ch == "-" || ch == "_" - } - } - - static func isHandleQuery(_ value: String) -> Bool { - value.allSatisfy { ch in - ch.isLetter || ch.isNumber || ch == "-" || ch == "_" || ch == "." - } - } - - static func isSessionQuery(_ value: String) -> Bool { - value.allSatisfy { ch in - ch.isLetter || ch.isNumber || ch == "-" || ch == "_" || ch == "." - } + static func index(in value: String, offset: Int) -> String.Index? { + MessageSuggestionEngine.index(in: value, offset: offset) } +} - static func index(in value: String, offset: Int) -> String.Index? { - guard offset >= 0, offset <= value.count else { return nil } - return value.index(value.startIndex, offsetBy: offset, limitedBy: value.endIndex) +private extension MessageSuggestionAgent { + init(_ agent: HudAgent) { + self.init( + id: agent.id, + name: agent.name, + handle: agent.handle, + state: agent.state.rawValue, + role: agent.role, + workspaceRoot: agent.projectRoot, + harnessSessionId: agent.harnessSessionId + ) } } diff --git a/apps/macos/Sources/HUD/HUDEngageState.swift b/apps/macos/Sources/OpenScoutMenu/HUD/HUDEngageState.swift similarity index 100% rename from apps/macos/Sources/HUD/HUDEngageState.swift rename to apps/macos/Sources/OpenScoutMenu/HUD/HUDEngageState.swift diff --git a/apps/macos/Sources/HUD/HUDFlashRow.swift b/apps/macos/Sources/OpenScoutMenu/HUD/HUDFlashRow.swift similarity index 100% rename from apps/macos/Sources/HUD/HUDFlashRow.swift rename to apps/macos/Sources/OpenScoutMenu/HUD/HUDFlashRow.swift diff --git a/apps/macos/Sources/HUD/HUDNavBus.swift b/apps/macos/Sources/OpenScoutMenu/HUD/HUDNavBus.swift similarity index 100% rename from apps/macos/Sources/HUD/HUDNavBus.swift rename to apps/macos/Sources/OpenScoutMenu/HUD/HUDNavBus.swift diff --git a/apps/macos/Sources/HUD/HUDRunnerState.swift b/apps/macos/Sources/OpenScoutMenu/HUD/HUDRunnerState.swift similarity index 99% rename from apps/macos/Sources/HUD/HUDRunnerState.swift rename to apps/macos/Sources/OpenScoutMenu/HUD/HUDRunnerState.swift index ae517253..736964d1 100644 --- a/apps/macos/Sources/HUD/HUDRunnerState.swift +++ b/apps/macos/Sources/OpenScoutMenu/HUD/HUDRunnerState.swift @@ -2,6 +2,7 @@ import AppKit import Combine import Foundation import ScoutNativeCore +import ScoutSharedUI @MainActor final class HUDRunnerState: ObservableObject { diff --git a/apps/macos/Sources/HUD/HUDRunnerView.swift b/apps/macos/Sources/OpenScoutMenu/HUD/HUDRunnerView.swift similarity index 99% rename from apps/macos/Sources/HUD/HUDRunnerView.swift rename to apps/macos/Sources/OpenScoutMenu/HUD/HUDRunnerView.swift index 75737ff1..81ca617d 100644 --- a/apps/macos/Sources/HUD/HUDRunnerView.swift +++ b/apps/macos/Sources/OpenScoutMenu/HUD/HUDRunnerView.swift @@ -1,3 +1,4 @@ +import ScoutSharedUI import SwiftUI private enum HUDRunnerFocusedField: Hashable { diff --git a/apps/macos/Sources/HUD/HUDSessionsView.swift b/apps/macos/Sources/OpenScoutMenu/HUD/HUDSessionsView.swift similarity index 100% rename from apps/macos/Sources/HUD/HUDSessionsView.swift rename to apps/macos/Sources/OpenScoutMenu/HUD/HUDSessionsView.swift diff --git a/apps/macos/Sources/HUD/HUDSizeToggle.swift b/apps/macos/Sources/OpenScoutMenu/HUD/HUDSizeToggle.swift similarity index 100% rename from apps/macos/Sources/HUD/HUDSizeToggle.swift rename to apps/macos/Sources/OpenScoutMenu/HUD/HUDSizeToggle.swift diff --git a/apps/macos/Sources/HUD/HUDState.swift b/apps/macos/Sources/OpenScoutMenu/HUD/HUDState.swift similarity index 100% rename from apps/macos/Sources/HUD/HUDState.swift rename to apps/macos/Sources/OpenScoutMenu/HUD/HUDState.swift diff --git a/apps/macos/Sources/HUD/HUDStatusView.swift b/apps/macos/Sources/OpenScoutMenu/HUD/HUDStatusView.swift similarity index 100% rename from apps/macos/Sources/HUD/HUDStatusView.swift rename to apps/macos/Sources/OpenScoutMenu/HUD/HUDStatusView.swift diff --git a/apps/macos/Sources/HUD/HUDTailView.swift b/apps/macos/Sources/OpenScoutMenu/HUD/HUDTailView.swift similarity index 100% rename from apps/macos/Sources/HUD/HUDTailView.swift rename to apps/macos/Sources/OpenScoutMenu/HUD/HUDTailView.swift diff --git a/apps/macos/Sources/HUD/HudAgent.swift b/apps/macos/Sources/OpenScoutMenu/HUD/HudAgent.swift similarity index 100% rename from apps/macos/Sources/HUD/HudAgent.swift rename to apps/macos/Sources/OpenScoutMenu/HUD/HudAgent.swift diff --git a/apps/macos/Sources/HUD/HudMessageDock.swift b/apps/macos/Sources/OpenScoutMenu/HUD/HudMessageDock.swift similarity index 74% rename from apps/macos/Sources/HUD/HudMessageDock.swift rename to apps/macos/Sources/OpenScoutMenu/HUD/HudMessageDock.swift index fff42662..336258e2 100644 --- a/apps/macos/Sources/HUD/HudMessageDock.swift +++ b/apps/macos/Sources/OpenScoutMenu/HUD/HudMessageDock.swift @@ -1,5 +1,6 @@ import AppKit import ScoutNativeCore +import ScoutSharedUI import SwiftUI // HudMessageDock — universal bottom-of-panel conversational dock. @@ -89,9 +90,10 @@ struct HudMessageDock: View { .frame(maxWidth: .infinity, alignment: .leading) .overlay(alignment: .topLeading) { if dock.suggestionsVisible { - DockSuggestionPopover( + MessageSuggestionPopover( suggestions: dock.suggestions, selectedIndex: dock.selectedSuggestionIndex, + style: .hud, onHover: { dock.selectSuggestion(index: $0) }, onSelect: { dock.applySuggestion($0) } ) @@ -137,112 +139,28 @@ struct HudMessageDock: View { } } -// ─── Suggestion popover ───────────────────────────────────────────── - -private struct DockSuggestionPopover: View { - let suggestions: [HUDDockSuggestion] - let selectedIndex: Int - let onHover: (Int) -> Void - let onSelect: (HUDDockSuggestion) -> Void - - private var visibleSuggestions: ArraySlice { - suggestions.prefix(7) - } - - private var eyebrow: String { - suggestions.first?.kind.eyebrow ?? "SUGGEST" - } - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - HUDEyebrow(text: eyebrow, color: HUDChrome.inkFaint) - .padding(.horizontal, 10) - .padding(.top, 7) - .padding(.bottom, 4) - - ForEach(Array(visibleSuggestions.enumerated()), id: \.element.id) { index, suggestion in - Button { - onSelect(suggestion) - } label: { - DockSuggestionRow( - suggestion: suggestion, - selected: index == selectedIndex - ) - } - .buttonStyle(.plain) - .onHover { hovering in - if hovering { onHover(index) } - } - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill(HUDChrome.canvasAlt) - ) - .overlay( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .stroke(HUDChrome.borderStrong, lineWidth: 0.75) - ) - .shadow(color: Color.black.opacity(0.24), radius: 10, x: 0, y: 4) - } -} - -private struct DockSuggestionRow: View { - let suggestion: HUDDockSuggestion - let selected: Bool - - private var accent: Color { - switch suggestion.kind { - case .command: return HUDChrome.accent - case .agent: return HUDChrome.ink - case .session: return HUDChrome.accentDim - } - } - - var body: some View { - HStack(spacing: 9) { - Text(kindMark) - .font(HUDType.mono(9, weight: .bold)) - .foregroundStyle(selected ? accent : HUDChrome.inkFaint) - .frame(width: 24, alignment: .leading) - - VStack(alignment: .leading, spacing: 1) { - Text(suggestion.label) - .font(HUDType.mono(11, weight: .semibold)) - .foregroundStyle(selected ? HUDChrome.ink : HUDChrome.inkMuted) - .lineLimit(1) - .truncationMode(.middle) - Text(suggestion.detail) - .font(HUDType.body(10)) - .foregroundStyle(HUDChrome.inkFaint) - .lineLimit(1) - .truncationMode(.tail) - } - Spacer(minLength: 0) - } - .padding(.horizontal, 10) - .padding(.vertical, 5) - .frame(maxWidth: .infinity, minHeight: 38, alignment: .leading) - .background( - Rectangle() - .fill(selected ? HUDChrome.canvasLift.opacity(0.62) : Color.clear) - ) - .overlay(alignment: .leading) { - Rectangle() - .fill(selected ? accent : Color.clear) - .frame(width: 1.5) - } - .contentShape(Rectangle()) - } - - private var kindMark: String { - switch suggestion.kind { - case .command: return "/" - case .agent: return "@" - case .session: return "sid" - } - } +// ─── Shared input atom styles ─────────────────────────────────────── + +private extension MessageSuggestionPopoverStyle { + static let hud = MessageSuggestionPopoverStyle( + eyebrowFont: HUDType.mono(10, weight: .bold), + markFont: HUDType.mono(9, weight: .bold), + labelFont: HUDType.mono(11, weight: .semibold), + detailFont: HUDType.body(10), + eyebrowColor: HUDChrome.inkFaint, + commandAccent: HUDChrome.accent, + agentAccent: HUDChrome.ink, + sessionAccent: HUDChrome.accentDim, + selectedLabelColor: HUDChrome.ink, + labelColor: HUDChrome.inkMuted, + detailColor: HUDChrome.inkFaint, + selectedBackgroundColor: HUDChrome.canvasLift.opacity(0.62), + backgroundColor: HUDChrome.canvasAlt, + borderColor: HUDChrome.borderStrong, + shadowColor: Color.black.opacity(0.24), + cornerRadius: 6, + borderWidth: 0.75 + ) } // ─── Compact — single 32px row ────────────────────────────────────── @@ -269,8 +187,8 @@ private struct CompactDock: View { MicButton(box: 20, glyph: 12) if let target { - TargetChip(label: target) - ThreadPill(name: threadName) + MessageRouteChip(label: target, style: .hud) + MessageContextPill(name: threadName, style: .hud) } ZStack(alignment: .leading) { @@ -287,7 +205,7 @@ private struct CompactDock: View { } .frame(maxWidth: .infinity, alignment: .leading) - SendChip(small: true, dimmed: text.isEmpty || isSending, onTap: onSubmit) + MessageSendChip(isEnabled: !text.isEmpty, isSending: isSending, style: .hud(small: true), action: onSubmit) EscChip() HyperKeyChip() } @@ -339,9 +257,9 @@ private struct MediumLargeDock: View { MicButton(box: micBox, glyph: micGlyph) if let target { - TargetChip(label: target) + MessageRouteChip(label: target, style: .hud) .padding(.top, isLarge ? 6 : 4) - ThreadPill(name: threadName) + MessageContextPill(name: threadName, style: .hud) .padding(.top, isLarge ? 6 : 4) } @@ -368,7 +286,7 @@ private struct MediumLargeDock: View { .frame(maxWidth: .infinity, alignment: .leading) .padding(.top, isLarge ? 4 : 3) - SendChip(small: false, dimmed: text.isEmpty || isSending, onTap: onSubmit) + MessageSendChip(isEnabled: !text.isEmpty, isSending: isSending, style: .hud(small: false), action: onSubmit) .padding(.top, isLarge ? 6 : 4) HStack(spacing: 8) { @@ -447,82 +365,38 @@ private struct DictationLivePreview: View { } } -// ─── Target chip (telegraphs routing) ─────────────────────────────── - -private struct TargetChip: View { - let label: String - - var body: some View { - HStack(spacing: 3) { - Text(label.hasPrefix("@") ? label : "@" + label) - .font(HUDType.mono(10, weight: .semibold)) - .foregroundStyle(HUDChrome.accent) - } - .padding(.horizontal, 6) - .padding(.vertical, 2) - .overlay( - RoundedRectangle(cornerRadius: 3, style: .continuous) - .stroke(HUDChrome.accent.opacity(0.45), lineWidth: 0.5) - ) - .fixedSize() - } +private extension MessageRouteChipStyle { + static let hud = MessageRouteChipStyle( + font: HUDType.mono(10, weight: .semibold), + textColor: HUDChrome.accent, + borderColor: HUDChrome.accent.opacity(0.45), + horizontalPadding: 6, + verticalPadding: 2, + cornerRadius: 3 + ) } -// ─── Thread pill (which scoutbot thread the send lands in) ────────── -// -// Subtle, borderless, mid-dot separator: reads as secondary metadata -// next to the @target chip rather than a second chip competing for -// attention. Static in stage 1; stage 2 will swap this for an -// interactive switcher. -private struct ThreadPill: View { - let name: String - - var body: some View { - HStack(spacing: 3) { - Text("·") - .font(HUDType.mono(10, weight: .semibold)) - .foregroundStyle(HUDChrome.inkFaint) - Text(name) - .font(HUDType.mono(10)) - .foregroundStyle(HUDChrome.inkMuted) - } - .fixedSize() - } +private extension MessageContextPillStyle { + static let hud = MessageContextPillStyle( + separatorFont: HUDType.mono(10, weight: .semibold), + textFont: HUDType.mono(10), + separatorColor: HUDChrome.inkFaint, + textColor: HUDChrome.inkMuted + ) } -// ─── SEND chip (lights up when text is present) ───────────────────── - -private struct SendChip: View { - let small: Bool - let dimmed: Bool - let onTap: () -> Void - - @State private var hovered = false - - private var color: Color { - if dimmed { return HUDChrome.inkFaint } - return hovered ? HUDChrome.ink : HUDChrome.accent - } - - var body: some View { - Button(action: onTap) { - HStack(spacing: 4) { - Text("↵") - .font(HUDType.mono(small ? 9 : 10, weight: .semibold)) - .foregroundStyle(color) - Text("SEND") - .font(HUDType.mono(small ? 9 : 10, weight: .semibold)) - .tracking(HUDType.eyebrowMicro) - .foregroundStyle(color) - } - .padding(.horizontal, 4) - .padding(.vertical, 2) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .disabled(dimmed) - .onHover { hovered = $0 } - .help(dimmed ? "" : "Send (↵)") +private extension MessageSendChipStyle { + static func hud(small: Bool) -> MessageSendChipStyle { + MessageSendChipStyle( + keyFont: HUDType.mono(small ? 9 : 10, weight: .semibold), + titleFont: HUDType.mono(small ? 9 : 10, weight: .semibold), + tracking: HUDType.eyebrowMicro, + enabledColor: HUDChrome.accent, + hoverColor: HUDChrome.ink, + disabledColor: HUDChrome.inkFaint, + horizontalPadding: 4, + verticalPadding: 2 + ) } } diff --git a/apps/macos/Sources/HUD/OverlayPanelShell.swift b/apps/macos/Sources/OpenScoutMenu/HUD/OverlayPanelShell.swift similarity index 100% rename from apps/macos/Sources/HUD/OverlayPanelShell.swift rename to apps/macos/Sources/OpenScoutMenu/HUD/OverlayPanelShell.swift diff --git a/apps/macos/Sources/OpenScoutAppController.swift b/apps/macos/Sources/OpenScoutMenu/OpenScoutAppController.swift similarity index 96% rename from apps/macos/Sources/OpenScoutAppController.swift rename to apps/macos/Sources/OpenScoutMenu/OpenScoutAppController.swift index 144a7785..ae67fccf 100644 --- a/apps/macos/Sources/OpenScoutAppController.swift +++ b/apps/macos/Sources/OpenScoutMenu/OpenScoutAppController.swift @@ -189,6 +189,12 @@ final class OpenScoutAppController: ObservableObject { openWebPath("/") } + func openComms(cId: String? = nil) { + Task { + await openCommsNow(cId: cId) + } + } + func openWebPath(_ path: String) { Task { await openWebSurfaceNow(path: path) @@ -486,7 +492,29 @@ final class OpenScoutAppController: ObservableObject { requestRefresh(reason: .manual) } -private func ensureWebServerRunning() async throws { + private func openCommsNow(cId: String?) async { + guard !webActionPending else { + return + } + + webActionPending = true + defer { + webActionPending = false + } + + lastError = nil + + do { + try await ensureWebServerRunning() + CommsWindowController.shared.show(cId: cId) + } catch { + lastError = error.localizedDescription + } + + requestRefresh(reason: .manual) + } + + private func ensureWebServerRunning() async throws { if await isWebSurfaceReachable() { return } diff --git a/apps/macos/Sources/OpenScoutMenuApp.swift b/apps/macos/Sources/OpenScoutMenu/OpenScoutMenuApp.swift similarity index 100% rename from apps/macos/Sources/OpenScoutMenuApp.swift rename to apps/macos/Sources/OpenScoutMenu/OpenScoutMenuApp.swift diff --git a/apps/macos/Sources/Services/BrokerService.swift b/apps/macos/Sources/OpenScoutMenu/Services/BrokerService.swift similarity index 100% rename from apps/macos/Sources/Services/BrokerService.swift rename to apps/macos/Sources/OpenScoutMenu/Services/BrokerService.swift diff --git a/apps/macos/Sources/Services/CommandRunner.swift b/apps/macos/Sources/OpenScoutMenu/Services/CommandRunner.swift similarity index 100% rename from apps/macos/Sources/Services/CommandRunner.swift rename to apps/macos/Sources/OpenScoutMenu/Services/CommandRunner.swift diff --git a/apps/macos/Sources/OpenScoutMenu/Services/CommsService.swift b/apps/macos/Sources/OpenScoutMenu/Services/CommsService.swift new file mode 100644 index 00000000..87493aca --- /dev/null +++ b/apps/macos/Sources/OpenScoutMenu/Services/CommsService.swift @@ -0,0 +1,351 @@ +import Combine +import Foundation + +enum CommsFilter: String, CaseIterable, Identifiable { + case all + case `private` + case shared + + var id: String { rawValue } + + var label: String { + switch self { + case .all: return "All" + case .private: return "Private" + case .shared: return "Shared" + } + } +} + +enum CommsScope { + case `private` + case shared +} + +struct CommsItem: Identifiable, Decodable, Sendable { + let cId: String + let kind: String + let title: String + let alias: String? + let participantIds: [String] + let agentId: String? + let agentName: String? + let harness: String? + let preview: String? + let messageCount: Int + let lastMessageAt: TimeInterval? + let workspaceRoot: String? + let currentBranch: String? + + var id: String { cId } + + var displayTitle: String { + if let alias, !alias.isEmpty { return alias } + if let agentName, !agentName.isEmpty { return agentName } + return title + } + + var scope: CommsScope { + if kind == "direct", participantIds.count <= 2 { + return .private + } + return .shared + } + + var scopeLabel: String { + switch scope { + case .private: return "Private" + case .shared: return "Shared" + } + } + + var cIdShort: String { + if cId.hasPrefix("c.") { + let rest = String(cId.dropFirst("c.".count)) + return "cId \(String(rest.prefix(8)))" + } + if cId.hasPrefix("dm.") { + return "cId legacy-dm" + } + if cId.hasPrefix("channel.") { + return "cId #\(String(cId.dropFirst("channel.".count)))" + } + return cId.count > 16 ? "cId \(String(cId.prefix(12)))" : "cId \(cId)" + } + + enum CodingKeys: String, CodingKey { + case cId + case fallbackId = "id" + case kind + case title + case alias + case participantIds + case agentId + case agentName + case harness + case preview + case messageCount + case lastMessageAt + case workspaceRoot + case currentBranch + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + cId = try c.decodeIfPresent(String.self, forKey: .cId) + ?? c.decode(String.self, forKey: .fallbackId) + kind = try c.decode(String.self, forKey: .kind) + title = try c.decode(String.self, forKey: .title) + alias = try c.decodeIfPresent(String.self, forKey: .alias) + participantIds = try c.decodeIfPresent([String].self, forKey: .participantIds) ?? [] + agentId = try c.decodeIfPresent(String.self, forKey: .agentId) + agentName = try c.decodeIfPresent(String.self, forKey: .agentName) + harness = try c.decodeIfPresent(String.self, forKey: .harness) + preview = try c.decodeIfPresent(String.self, forKey: .preview) + messageCount = try c.decodeIfPresent(Int.self, forKey: .messageCount) ?? 0 + lastMessageAt = try c.decodeIfPresent(TimeInterval.self, forKey: .lastMessageAt) + workspaceRoot = try c.decodeIfPresent(String.self, forKey: .workspaceRoot) + currentBranch = try c.decodeIfPresent(String.self, forKey: .currentBranch) + } +} + +struct CommsMessage: Identifiable, Decodable, Sendable { + let id: String + let cId: String + let actorId: String? + let actorName: String + let body: String + let createdAt: TimeInterval + let messageClass: String + + var isOperator: Bool { + actorId == "operator" || messageClass == "operator" || actorName.lowercased() == "operator" + } + + enum CodingKeys: String, CodingKey { + case id + case cId + case fallbackCId = "conversationId" + case actorId + case actorName + case body + case createdAt + case messageClass = "class" + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(String.self, forKey: .id) + cId = try c.decodeIfPresent(String.self, forKey: .cId) + ?? c.decode(String.self, forKey: .fallbackCId) + actorId = try c.decodeIfPresent(String.self, forKey: .actorId) + actorName = try c.decodeIfPresent(String.self, forKey: .actorName) + ?? actorId + ?? "unknown" + body = try c.decode(String.self, forKey: .body) + createdAt = try c.decode(TimeInterval.self, forKey: .createdAt) + messageClass = try c.decodeIfPresent(String.self, forKey: .messageClass) ?? "message" + } +} + +@MainActor +final class CommsService: ObservableObject { + static let shared = CommsService() + + @Published private(set) var items: [CommsItem] = [] + @Published private(set) var messages: [CommsMessage] = [] + @Published private(set) var selectedCId: String? + @Published var filter: CommsFilter = .all + @Published private(set) var isLoading = false + @Published private(set) var isSending = false + @Published private(set) var lastError: String? + + private let decoder = JSONDecoder() + private var pollTask: Task? + private var itemsTask: Task? + private var messagesTask: Task? + + private init() {} + + var selectedItem: CommsItem? { + guard let selectedCId else { return nil } + return items.first { $0.cId == selectedCId } + } + + var filteredItems: [CommsItem] { + items.filter { item in + switch filter { + case .all: return true + case .private: return item.scope == .private + case .shared: return item.scope == .shared + } + } + } + + func start(preferredCId: String? = nil) { + if let preferredCId { + selectedCId = preferredCId + } + guard pollTask == nil else { + refresh(force: true) + return + } + refresh(force: true) + pollTask = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 2_500_000_000) + self?.refresh() + } + } + } + + func stop() { + pollTask?.cancel() + pollTask = nil + itemsTask?.cancel() + messagesTask?.cancel() + itemsTask = nil + messagesTask = nil + } + + func refresh(force: Bool = false) { + if itemsTask != nil { return } + if !force, pollTask == nil { return } + isLoading = items.isEmpty + itemsTask = Task { [weak self] in + await self?.loadItems() + } + } + + func select(_ cId: String) { + guard selectedCId != cId else { return } + selectedCId = cId + messages = [] + loadMessages() + } + + func loadMessages() { + guard let selectedCId else { return } + messagesTask?.cancel() + messagesTask = Task { [weak self] in + await self?.loadMessages(cId: selectedCId) + } + } + + func send(_ body: String) async { + let trimmed = body.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, let selectedCId, !isSending else { return } + isSending = true + defer { isSending = false } + do { + let url = HudFleetService.webBaseURL().appending(path: "api/send") + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONSerialization.data(withJSONObject: [ + "body": trimmed, + "cId": selectedCId, + "conversationId": selectedCId, + ]) + let (_, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { + throw CommsServiceError.sendFailed + } + lastError = nil + refresh(force: true) + loadMessages() + } catch { + lastError = Self.userFacingError(error) + } + } + + private func loadItems() async { + defer { + isLoading = false + itemsTask = nil + } + do { + let base = HudFleetService.webBaseURL() + let commsURL = base + .appending(path: "api/comms") + .appending(queryItems: [URLQueryItem(name: "limit", value: "120")]) + let fallbackURL = base + .appending(path: "api/conversations") + .appending(queryItems: [URLQueryItem(name: "limit", value: "120")]) + let next = try await fetchWithFallback([CommsItem].self, primary: commsURL, fallback: fallbackURL) + items = next + if selectedCId == nil || !next.contains(where: { $0.cId == selectedCId }) { + selectedCId = next.first?.cId + } + lastError = nil + loadMessages() + } catch { + lastError = Self.userFacingError(error) + } + } + + private func loadMessages(cId: String) async { + defer { + messagesTask = nil + } + do { + let url = HudFleetService.webBaseURL() + .appending(path: "api/messages") + .appending(queryItems: [ + URLQueryItem(name: "cId", value: cId), + URLQueryItem(name: "conversationId", value: cId), + URLQueryItem(name: "limit", value: "220"), + ]) + let next = try await fetch([CommsMessage].self, from: url) + guard selectedCId == cId else { return } + messages = next.sorted { $0.createdAt < $1.createdAt } + lastError = nil + } catch { + guard selectedCId == cId else { return } + lastError = Self.userFacingError(error) + } + } + + private func fetch(_ type: T.Type, from url: URL) async throws -> T { + let (data, response) = try await URLSession.shared.data(from: url) + guard let http = response as? HTTPURLResponse else { + throw CommsServiceError.invalidResponse + } + guard (200..<300).contains(http.statusCode) else { + throw CommsServiceError.httpStatus(http.statusCode) + } + return try decoder.decode(type, from: data) + } + + private func fetchWithFallback(_ type: T.Type, primary: URL, fallback: URL) async throws -> T { + do { + return try await fetch(type, from: primary) + } catch { + return try await fetch(type, from: fallback) + } + } + + private static func userFacingError(_ error: Error) -> String { + if let commsError = error as? CommsServiceError { + return commsError.localizedDescription + } + return error.localizedDescription + } +} + +enum CommsServiceError: LocalizedError { + case invalidResponse + case httpStatus(Int) + case sendFailed + + var errorDescription: String? { + switch self { + case .invalidResponse: + return "Comms returned an invalid response." + case .httpStatus(let status): + return "Comms returned HTTP \(status)." + case .sendFailed: + return "Comms send failed." + } + } +} diff --git a/apps/macos/Sources/Services/HUDStateFile.swift b/apps/macos/Sources/OpenScoutMenu/Services/HUDStateFile.swift similarity index 100% rename from apps/macos/Sources/Services/HUDStateFile.swift rename to apps/macos/Sources/OpenScoutMenu/Services/HUDStateFile.swift diff --git a/apps/macos/Sources/Services/HUDURLRouter.swift b/apps/macos/Sources/OpenScoutMenu/Services/HUDURLRouter.swift similarity index 100% rename from apps/macos/Sources/Services/HUDURLRouter.swift rename to apps/macos/Sources/OpenScoutMenu/Services/HUDURLRouter.swift diff --git a/apps/macos/Sources/Services/HotkeyManager.swift b/apps/macos/Sources/OpenScoutMenu/Services/HotkeyManager.swift similarity index 99% rename from apps/macos/Sources/Services/HotkeyManager.swift rename to apps/macos/Sources/OpenScoutMenu/Services/HotkeyManager.swift index d4557346..b4254970 100644 --- a/apps/macos/Sources/Services/HotkeyManager.swift +++ b/apps/macos/Sources/OpenScoutMenu/Services/HotkeyManager.swift @@ -118,5 +118,6 @@ enum CarbonModifier { // Common keyCodes we use here. Source: HIToolbox/Events.h. enum CarbonKeyCode { + static let c: UInt32 = 8 static let h: UInt32 = 4 } diff --git a/apps/macos/Sources/Services/HudComposeService.swift b/apps/macos/Sources/OpenScoutMenu/Services/HudComposeService.swift similarity index 100% rename from apps/macos/Sources/Services/HudComposeService.swift rename to apps/macos/Sources/OpenScoutMenu/Services/HudComposeService.swift diff --git a/apps/macos/Sources/Services/HudFleetService.swift b/apps/macos/Sources/OpenScoutMenu/Services/HudFleetService.swift similarity index 100% rename from apps/macos/Sources/Services/HudFleetService.swift rename to apps/macos/Sources/OpenScoutMenu/Services/HudFleetService.swift diff --git a/apps/macos/Sources/Services/HudRunnerService.swift b/apps/macos/Sources/OpenScoutMenu/Services/HudRunnerService.swift similarity index 100% rename from apps/macos/Sources/Services/HudRunnerService.swift rename to apps/macos/Sources/OpenScoutMenu/Services/HudRunnerService.swift diff --git a/apps/macos/Sources/Services/OpenScoutPathResolver.swift b/apps/macos/Sources/OpenScoutMenu/Services/OpenScoutPathResolver.swift similarity index 100% rename from apps/macos/Sources/Services/OpenScoutPathResolver.swift rename to apps/macos/Sources/OpenScoutMenu/Services/OpenScoutPathResolver.swift diff --git a/apps/macos/Sources/Services/OpenScoutToolchain.swift b/apps/macos/Sources/OpenScoutMenu/Services/OpenScoutToolchain.swift similarity index 100% rename from apps/macos/Sources/Services/OpenScoutToolchain.swift rename to apps/macos/Sources/OpenScoutMenu/Services/OpenScoutToolchain.swift diff --git a/apps/macos/Sources/Services/PairingService.swift b/apps/macos/Sources/OpenScoutMenu/Services/PairingService.swift similarity index 100% rename from apps/macos/Sources/Services/PairingService.swift rename to apps/macos/Sources/OpenScoutMenu/Services/PairingService.swift diff --git a/apps/macos/Sources/Services/SessionScanner.swift b/apps/macos/Sources/OpenScoutMenu/Services/SessionScanner.swift similarity index 100% rename from apps/macos/Sources/Services/SessionScanner.swift rename to apps/macos/Sources/OpenScoutMenu/Services/SessionScanner.swift diff --git a/apps/macos/Sources/Services/TailscaleService.swift b/apps/macos/Sources/OpenScoutMenu/Services/TailscaleService.swift similarity index 100% rename from apps/macos/Sources/Services/TailscaleService.swift rename to apps/macos/Sources/OpenScoutMenu/Services/TailscaleService.swift diff --git a/apps/macos/Sources/Views/ActionLogPanel.swift b/apps/macos/Sources/OpenScoutMenu/Views/ActionLogPanel.swift similarity index 100% rename from apps/macos/Sources/Views/ActionLogPanel.swift rename to apps/macos/Sources/OpenScoutMenu/Views/ActionLogPanel.swift diff --git a/apps/macos/Sources/OpenScoutMenu/Views/CommsMessageMarkup.swift b/apps/macos/Sources/OpenScoutMenu/Views/CommsMessageMarkup.swift new file mode 100644 index 00000000..0adada8d --- /dev/null +++ b/apps/macos/Sources/OpenScoutMenu/Views/CommsMessageMarkup.swift @@ -0,0 +1,162 @@ +import ScoutSharedUI +import SwiftUI + +struct CommsMessageMarkup: View { + let text: String + + private var blocks: [MessageMarkupBlock] { + MessageMarkupParser.parse(text) + } + + var body: some View { + VStack(alignment: .leading, spacing: 9) { + ForEach(blocks) { block in + switch block.kind { + case .paragraph: + CommsMarkdownText(block.text) + case .heading(let depth): + CommsMarkdownText( + block.text, + font: HUDType.body(depth <= 2 ? 15 : 14, weight: .semibold), + color: HUDChrome.ink + ) + .padding(.top, depth <= 2 ? 2 : 0) + case .rule: + HUDHairline() + .padding(.vertical, 3) + case .list(let ordered, let items): + CommsMarkdownList(ordered: ordered, items: items) + case .blockquote: + CommsMarkdownQuote(text: block.text) + case .code(let language): + MessageCodeBlock(language: language, text: block.text, style: .comms) + case .table(let headers, let rows): + CommsMarkdownTable(headers: headers, rows: rows) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +private struct CommsMarkdownText: View { + let text: String + var font: Font = HUDType.body(13) + var color: Color = HUDChrome.ink + + init(_ text: String, font: Font = HUDType.body(13), color: Color = HUDChrome.ink) { + self.text = text + self.font = font + self.color = color + } + + var body: some View { + Text(markdown(text)) + .font(font) + .foregroundStyle(color) + .lineSpacing(2) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func markdown(_ text: String) -> AttributedString { + let options = AttributedString.MarkdownParsingOptions( + interpretedSyntax: .inlineOnlyPreservingWhitespace + ) + if let attributed = try? AttributedString(markdown: text, options: options) { + return attributed + } + return AttributedString(text) + } +} + +private struct CommsMarkdownList: View { + let ordered: Bool + let items: [String] + + var body: some View { + VStack(alignment: .leading, spacing: 5) { + ForEach(Array(items.enumerated()), id: \.offset) { index, item in + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(ordered ? "\(index + 1)." : "-") + .font(HUDType.mono(11, weight: .bold)) + .foregroundStyle(HUDChrome.inkFaint) + .frame(width: ordered ? 22 : 12, alignment: .trailing) + CommsMarkdownText(item) + } + } + } + } +} + +private struct CommsMarkdownQuote: View { + let text: String + + var body: some View { + HStack(alignment: .top, spacing: 9) { + RoundedRectangle(cornerRadius: 1, style: .continuous) + .fill(HUDChrome.accentSoft) + .frame(width: 3) + CommsMarkdownText(text, color: HUDChrome.inkMuted) + } + .padding(.vertical, 2) + } +} + +private struct CommsMarkdownTable: View { + let headers: [String] + let rows: [[String]] + + var body: some View { + ScrollView(.horizontal) { + VStack(alignment: .leading, spacing: 0) { + tableRow(headers, isHeader: true) + HUDHairline() + ForEach(Array(rows.enumerated()), id: \.offset) { _, row in + tableRow(row, isHeader: false) + } + } + .background(HUDChrome.canvas.opacity(0.55)) + .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .stroke(HUDChrome.borderSoft, lineWidth: 1) + ) + } + .scrollIndicators(.hidden) + } + + private func tableRow(_ cells: [String], isHeader: Bool) -> some View { + HStack(spacing: 0) { + ForEach(0..= (1.0 / 60.0) else { return } + lastObserveResizeFrameAt = now + positionObserveWindow(display: false) + } + + func endObserveResize() { + observeResizeStartWidth = nil + lastObserveResizeFrameAt = 0 + positionObserveWindow(display: true) + } + + func beginFamilyDrag() { + familyDragStartFrame = window?.frame + window?.makeKeyAndOrderFront(nil) + } + + func dragFamily(screenTranslation: CGSize) { + guard let window else { return } + if familyDragStartFrame == nil { + familyDragStartFrame = window.frame + window.makeKeyAndOrderFront(nil) + } + guard let startFrame = familyDragStartFrame else { return } + var nextFrame = startFrame + nextFrame.origin.x += screenTranslation.width + nextFrame.origin.y += screenTranslation.height + window.setFrame(nextFrame, display: false) + } + + func endFamilyDrag() { + familyDragStartFrame = nil + positionObserveWindow(display: true) + } + + func windowWillClose(_ notification: Notification) { + closeObserve() + service.stop() + window = nil + restoreAccessoryMode() + } + + func windowDidMove(_ notification: Notification) { + guard notification.object as? NSWindow === window else { return } + } + + func windowDidResize(_ notification: Notification) { + guard notification.object as? NSWindow === window else { return } + positionObserveWindow() + } + + /// True while the Comms panel is on screen. The HUD dock checks this so + /// the foreground Comms surface owns Vox dictation (splice + consume) + /// rather than the dock stealing the final transcript. + var isPresented: Bool { window?.isVisible == true } + + private func makeWindow() -> NSWindow { + let hosting = NSHostingController(rootView: CommsRootView(service: service)) + let window = CommsAppWindow( + contentRect: NSRect(origin: .zero, size: Self.baseContentSize), + styleMask: [.borderless, .resizable], + backing: .buffered, + defer: false + ) + window.contentViewController = hosting + window.title = "OpenScout Comms" + window.isMovableByWindowBackground = true + window.isReleasedWhenClosed = false + window.contentMinSize = NSSize(width: 780, height: 540) + window.minSize = NSSize(width: 780, height: 540) + window.level = .normal + window.collectionBehavior = [] + window.sharingType = .readOnly + window.hasShadow = true + window.isOpaque = false + window.backgroundColor = .clear + window.appearance = NSAppearance(named: .darkAqua) + window.delegate = self + window.setContentSize(Self.baseContentSize) + return window + } + + private func makeObserveWindow(target: CommsObserveTarget) -> NSWindow { + let window = CommsSidecarWindow( + contentRect: NSRect( + origin: .zero, + size: NSSize(width: Self.observeShelfWidth, height: Self.baseContentSize.height) + ), + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + window.contentViewController = NSHostingController( + rootView: CommsObserveSidecarView(target: target) { + CommsWindowController.shared.closeObserve() + } onReady: { + CommsWindowController.shared.presentObserveIfReady(targetID: target.id) + } + ) + window.title = "OpenScout Observe" + window.level = self.window?.level ?? .normal + window.collectionBehavior = [.fullScreenAuxiliary] + window.sharingType = .readOnly + window.hasShadow = true + window.isOpaque = false + window.backgroundColor = .clear + window.appearance = NSAppearance(named: .darkAqua) + return window + } + + private func positionObserveWindow(animated: Bool = false, display: Bool = true) { + guard let window, let observeWindow else { return } + let frame = window.frame + let contentFrame = window.convertToScreen(window.contentLayoutRect) + let nextFrame = NSRect( + x: frame.maxX - Self.observeAttachOverlap, + y: contentFrame.minY, + width: observeWindowWidth + Self.observeAttachOverlap, + height: contentFrame.height + ) + + guard animated else { + observeWindow.setFrame(nextFrame, display: display) + return + } + + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.16 + context.allowsImplicitAnimation = true + observeWindow.animator().setFrame(nextFrame, display: true) + } + } + + private func promoteToAppWindowMode() { + guard NSApp.activationPolicy() != .regular else { return } + promotedActivationPolicy = true + NSApp.setActivationPolicy(.regular) + } + + private func restoreAccessoryMode() { + guard promotedActivationPolicy else { return } + promotedActivationPolicy = false + NSApp.setActivationPolicy(.accessory) + } + +} + +private final class CommsAppWindow: NSWindow { + override var canBecomeKey: Bool { true } + override var canBecomeMain: Bool { true } +} + +private final class CommsSidecarWindow: NSWindow { + override var canBecomeKey: Bool { true } + override var canBecomeMain: Bool { false } +} + +private extension Notification.Name { + static let commsObserveClosed = Notification.Name("OpenScoutCommsObserveClosed") +} + +struct CommsRootView: View { + @ObservedObject var service: CommsService + + enum Field: Hashable { case search, composer, command } + + // Drafts are kept per-channel so switching cId never loses in-progress + // text — a core "don't surprise me" affordance for a comms tool. + @State private var drafts: [String: String] = [:] + @State private var channelQuery = "" + @State private var showCommands = false + @State private var commandQuery = "" + @State private var commandIndex = 0 + @State private var observeTarget: CommsObserveTarget? + @FocusState private var focus: Field? + @ObservedObject private var vox = HudVoxService.shared + + private let observeClosedPublisher = NotificationCenter.default.publisher(for: .commsObserveClosed) + + var body: some View { + ZStack(alignment: .leading) { + VisualEffectBackground(material: .hudWindow, cornerRadius: 8) + HUDChrome.canvas + HUDPaperGrain(opacity: 0.03) + + VStack(spacing: 0) { + header + HUDHairline() + main + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background(keyboardCommands) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(HUDChrome.borderRim, lineWidth: 1) + ) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + .preferredColorScheme(.dark) + .onAppear { + focusComposerSoon() + } + .onChange(of: service.selectedCId) { _, _ in + closeObserve() + focusComposerSoon() + } + .onReceive(observeClosedPublisher) { _ in + observeTarget = nil + } + } + + private var header: some View { + HStack(spacing: 12) { + Text("C") + .font(HUDType.mono(15, weight: .bold)) + .foregroundStyle(HUDChrome.canvas) + .frame(width: 28, height: 28) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(HUDChrome.accent) + ) + + VStack(alignment: .leading, spacing: 2) { + Text("COMMS") + .font(HUDType.mono(11, weight: .bold)) + .tracking(HUDType.eyebrowTracking) + .foregroundStyle(HUDChrome.ink) + Text(service.selectedItem?.cIdShort ?? "cId") + .font(HUDType.mono(10)) + .foregroundStyle(HUDChrome.inkFaint) + .lineLimit(1) + } + + Spacer(minLength: 10) + + HStack(spacing: 4) { + ForEach(CommsFilter.allCases) { filter in + CommsFilterButton( + label: filter.label, + isSelected: service.filter == filter + ) { + service.filter = filter + } + } + } + + Button { + service.refresh(force: true) + service.loadMessages() + } label: { + Image(systemName: "arrow.clockwise") + .font(.system(size: 12, weight: .semibold)) + } + .buttonStyle(CommsIconButtonStyle()) + .help("Refresh") + + Button { + CommsWindowController.shared.dismiss() + } label: { + Image(systemName: "xmark") + .font(.system(size: 12, weight: .bold)) + } + .buttonStyle(CommsIconButtonStyle()) + .help("Close") + } + .padding(.horizontal, 18) + .frame(height: 54) + .background(HUDChrome.canvasAlt) + } + + private var main: some View { + HStack(spacing: 0) { + rail + .frame(width: 304) + HUDHairline(axis: .vertical) + detail + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private var rail: some View { + VStack(spacing: 0) { + HStack(spacing: 8) { + Text("\(visibleItems.count)") + .font(HUDType.mono(18, weight: .bold)) + .foregroundStyle(HUDChrome.ink) + Text("CHANNELS") + .font(HUDType.mono(10, weight: .bold)) + .tracking(HUDType.eyebrowTracking) + .foregroundStyle(HUDChrome.inkMuted) + Spacer() + if service.isLoading { + ProgressView() + .controlSize(.small) + .scaleEffect(0.7) + } + } + .padding(.horizontal, 14) + .frame(height: 44) + .background(HUDChrome.canvas) + + railSearch + + HUDHairline() + + if visibleItems.isEmpty { + railPlaceholder + } else { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(visibleItems) { item in + CommsRailRow( + item: item, + isSelected: service.selectedCId == item.cId + ) { + service.select(item.cId) + } + } + } + } + .scrollIndicators(.hidden) + } + } + .frame(maxHeight: .infinity, alignment: .topLeading) + .background(HUDChrome.canvas) + } + + private var railSearch: some View { + HStack(spacing: 7) { + Image(systemName: "magnifyingglass") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(HUDChrome.inkFaint) + TextField("Jump to channel", text: $channelQuery) + .textFieldStyle(.plain) + .font(HUDType.body(12)) + .foregroundStyle(HUDChrome.ink) + .focused($focus, equals: .search) + .onSubmit { + if let first = visibleItems.first { + service.select(first.cId) + channelQuery = "" + focus = .composer + } + } + if channelQuery.isEmpty { + Text("⌘K") + .font(HUDType.mono(9, weight: .bold)) + .foregroundStyle(HUDChrome.inkDeep) + } else { + Button { + channelQuery = "" + focus = .search + } label: { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 12)) + .foregroundStyle(HUDChrome.inkFaint) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 14) + .frame(height: 34) + .background(HUDChrome.canvas) + } + + @ViewBuilder + private var railPlaceholder: some View { + VStack(spacing: 10) { + if service.isLoading && service.items.isEmpty { + ProgressView() + .controlSize(.small) + Text("LOADING CHANNELS") + .font(HUDType.mono(10, weight: .bold)) + .tracking(HUDType.eyebrowTracking) + .foregroundStyle(HUDChrome.inkMuted) + } else if let error = service.lastError, !error.isEmpty, service.items.isEmpty { + Image(systemName: "wifi.exclamationmark") + .font(.system(size: 22, weight: .regular)) + .foregroundStyle(HUDChrome.inkDeep) + Text("COMMS UNREACHABLE") + .font(HUDType.mono(10, weight: .bold)) + .tracking(HUDType.eyebrowTracking) + .foregroundStyle(HUDChrome.inkMuted) + Text(error) + .font(HUDType.body(11)) + .foregroundStyle(HUDChrome.inkFaint) + .multilineTextAlignment(.center) + .lineLimit(3) + } else if !channelQuery.isEmpty { + Image(systemName: "magnifyingglass") + .font(.system(size: 22, weight: .regular)) + .foregroundStyle(HUDChrome.inkDeep) + Text("NO MATCHES") + .font(HUDType.mono(10, weight: .bold)) + .tracking(HUDType.eyebrowTracking) + .foregroundStyle(HUDChrome.inkMuted) + } else { + Image(systemName: "tray") + .font(.system(size: 22, weight: .regular)) + .foregroundStyle(HUDChrome.inkDeep) + Text(service.filter == .all ? "NO CHANNELS" : "NO \(service.filter.label.uppercased()) CHANNELS") + .font(HUDType.mono(10, weight: .bold)) + .tracking(HUDType.eyebrowTracking) + .foregroundStyle(HUDChrome.inkMuted) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.horizontal, 20) + .background(HUDChrome.canvas) + } + + @ViewBuilder + private var detail: some View { + if let item = service.selectedItem { + VStack(spacing: 0) { + detailHeader(item) + HUDHairline() + messages + HUDHairline() + composer + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background(HUDChrome.canvas) + } else { + VStack(spacing: 14) { + Image(systemName: "bubble.left.and.bubble.right") + .font(.system(size: 34, weight: .regular)) + .foregroundStyle(HUDChrome.inkDeep) + Text("NO CHANNEL SELECTED") + .font(HUDType.mono(11, weight: .bold)) + .tracking(HUDType.eyebrowTracking) + .foregroundStyle(HUDChrome.inkMuted) + if let error = service.lastError, !error.isEmpty { + Text(error) + .font(HUDType.body(12)) + .foregroundStyle(ShellPalette.error) + .multilineTextAlignment(.center) + .lineLimit(3) + .frame(maxWidth: 360) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(HUDChrome.canvas) + } + } + + private func detailHeader(_ item: CommsItem) -> some View { + HStack(alignment: .center, spacing: 12) { + VStack(alignment: .leading, spacing: 5) { + Text(item.displayTitle) + .font(HUDType.body(20, weight: .semibold)) + .foregroundStyle(HUDChrome.ink) + .lineLimit(1) + .truncationMode(.tail) + HStack(spacing: 8) { + CommsChip(text: item.scopeLabel.uppercased()) + CommsChip(text: item.cIdShort) + CommsMemberStrip(item: item) + } + } + Spacer(minLength: 12) + if let branch = item.currentBranch, !branch.isEmpty { + Text(branch) + .font(HUDType.mono(10, weight: .medium)) + .foregroundStyle(HUDChrome.inkMuted) + .lineLimit(1) + .truncationMode(.middle) + .frame(maxWidth: 180, alignment: .trailing) + } + if let target = observeTarget(for: item) { + Button { + openObserve(target) + } label: { + Label("Observe", systemImage: "eye") + .font(HUDType.mono(10, weight: .bold)) + .tracking(0.8) + .frame(height: 30) + .padding(.horizontal, 10) + } + .buttonStyle(CommsPillButtonStyle(isActive: observeTarget == target)) + .help(observeTarget == target ? "Close observe" : "Peek into this agent's work") + } + } + .padding(.horizontal, 20) + .frame(height: 72) + .background(HUDChrome.canvasAlt) + } + + private var messages: some View { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(spacing: 2) { + ForEach(Array(service.messages.enumerated()), id: \.element.id) { index, message in + CommsMessageRow( + message: message, + showsHeader: Self.showsHeader(at: index, in: service.messages), + observeTarget: observeTarget(for: message), + onObserve: openObserve + ) + .id(message.id) + .padding(.top, Self.showsHeader(at: index, in: service.messages) ? 8 : 0) + } + Color.clear + .frame(height: 1) + .id("bottom") + } + .padding(.horizontal, 20) + .padding(.vertical, 18) + } + .scrollIndicators(.visible) + .background(HUDChrome.canvas) + .overlay { + if service.messages.isEmpty { + VStack(spacing: 8) { + Image(systemName: "text.bubble") + .font(.system(size: 26, weight: .regular)) + .foregroundStyle(HUDChrome.inkDeep) + Text("NO MESSAGES YET") + .font(HUDType.mono(10, weight: .bold)) + .tracking(HUDType.eyebrowTracking) + .foregroundStyle(HUDChrome.inkMuted) + } + .allowsHitTesting(false) + } + } + .overlay(alignment: .bottom) { + if showCommands { + commandPalette + } + } + .onChange(of: service.messages.count) { _, _ in + withAnimation(.easeOut(duration: 0.16)) { + proxy.scrollTo("bottom", anchor: .bottom) + } + } + .onAppear { + proxy.scrollTo("bottom", anchor: .bottom) + } + } + } + + private var composer: some View { + VStack(spacing: 8) { + if let error = service.lastError, !error.isEmpty { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 11, weight: .semibold)) + Text(error) + .font(HUDType.body(12)) + .lineLimit(2) + Spacer() + } + .foregroundStyle(ShellPalette.error) + } + + HStack(alignment: .bottom, spacing: 10) { + Button { + toggleDictation() + } label: { + Image(systemName: micSymbol) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(isDictating ? HUDChrome.accent : HUDChrome.inkMuted) + .frame(width: 38, height: 38) + .background( + RoundedRectangle(cornerRadius: 7, style: .continuous) + .fill(HUDChrome.canvasLift) + ) + .overlay( + RoundedRectangle(cornerRadius: 7, style: .continuous) + .stroke(isDictating ? HUDChrome.accentDim : HUDChrome.border, lineWidth: 1) + ) + } + .buttonStyle(.plain) + .help(isDictating ? "Stop dictation" : "Dictate with Vox") + + Button { + showCommands ? closeCommands() : openCommands() + } label: { + Image(systemName: "command") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(showCommands ? HUDChrome.accent : HUDChrome.inkMuted) + .frame(width: 38, height: 38) + .background( + RoundedRectangle(cornerRadius: 7, style: .continuous) + .fill(HUDChrome.canvasLift) + ) + .overlay( + RoundedRectangle(cornerRadius: 7, style: .continuous) + .stroke(showCommands ? HUDChrome.accentDim : HUDChrome.border, lineWidth: 1) + ) + } + .buttonStyle(.plain) + .help("Commands — ⌘P or type /") + + TextField(composerPlaceholder, text: draftBinding, axis: .vertical) + .textFieldStyle(.plain) + .font(HUDType.body(14)) + .foregroundStyle(HUDChrome.ink) + .lineLimit(1...5) + .focused($focus, equals: .composer) + .onKeyPress(phases: .down) { press in + // Return sends; Shift+Return inserts a newline + // (Messages-style). ⌘↩ still works via the send button. + guard press.key == .return else { return .ignored } + if press.modifiers.contains(.shift) { return .ignored } + sendDraft() + return .handled + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 7, style: .continuous) + .fill(HUDChrome.canvasLift) + ) + .overlay( + RoundedRectangle(cornerRadius: 7, style: .continuous) + .stroke(focus == .composer ? HUDChrome.accentDim : HUDChrome.border, lineWidth: 1) + ) + + Button { + sendDraft() + } label: { + Image(systemName: service.isSending ? "hourglass" : "paperplane.fill") + .font(.system(size: 14, weight: .semibold)) + .frame(width: 38, height: 38) + } + .buttonStyle(CommsSendButtonStyle()) + .disabled(currentDraftEmpty || service.isSending) + .keyboardShortcut(.return, modifiers: [.command]) + .help("Send") + } + + if isDictating { + HStack(spacing: 6) { + Circle() + .fill(HUDChrome.accent) + .frame(width: 6, height: 6) + Text(voxStatusLine) + .font(HUDType.mono(9)) + .foregroundStyle(HUDChrome.inkMuted) + .lineLimit(1) + .truncationMode(.head) + Spacer() + } + } else if let reason = voxUnavailableReason { + HStack(spacing: 6) { + Image(systemName: "mic.slash") + .font(.system(size: 9, weight: .semibold)) + Text(reason) + .font(HUDType.mono(9)) + .lineLimit(1) + Spacer() + } + .foregroundStyle(HUDChrome.inkFaint) + } else if !currentDraftEmpty { + HStack(spacing: 0) { + Spacer() + Text("⏎ to send · ⇧⏎ newline") + .font(HUDType.mono(9)) + .foregroundStyle(HUDChrome.inkFaint) + } + } + } + .padding(.horizontal, 18) + .padding(.vertical, 14) + .background(HUDChrome.canvasAlt) + .onReceive(vox.$lastFinalText) { text in + spliceDictatedFinal(text) + } + } + + private func sendDraft() { + guard let cId = service.selectedCId else { return } + let body = drafts[cId] ?? "" + guard !body.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } + drafts[cId] = "" + focus = .composer + Task { + await service.send(body) + } + } + + // Per-channel draft binding — text follows the selected cId so switching + // channels parks the draft instead of discarding it. + private var draftBinding: Binding { + let key = service.selectedCId ?? "" + return Binding( + get: { drafts[key] ?? "" }, + set: { newValue in + // Typing "/" into an empty composer opens the command area + // instead of entering the slash as message text — "/" stays + // a control key, never a literal sent to an agent. + if newValue == "/" && (drafts[key] ?? "").isEmpty { + openCommands() + return + } + drafts[key] = newValue + } + ) + } + + private var currentDraftEmpty: Bool { + (drafts[service.selectedCId ?? ""] ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + .isEmpty + } + + // Scope filter (All/Private/Shared) plus the live quick-filter query. + private var visibleItems: [CommsItem] { + let scoped = service.filteredItems + let query = channelQuery.trimmingCharacters(in: .whitespaces).lowercased() + guard !query.isEmpty else { return scoped } + return scoped.filter { + $0.displayTitle.lowercased().contains(query) || $0.cId.lowercased().contains(query) + } + } + + private func moveSelection(_ delta: Int) { + let items = visibleItems + guard !items.isEmpty else { return } + let current = items.firstIndex { $0.cId == service.selectedCId } ?? -1 + let next = max(0, min(items.count - 1, current + delta)) + guard items.indices.contains(next) else { return } + service.select(items[next].cId) + } + + private func focusComposerSoon() { + DispatchQueue.main.async { focus = .composer } + } + + // Routes to the focused field's editor via the standard responder action, + // the same one Edit ▸ Start Dictation uses. No-op if nothing accepts it. + // ── Dictation (Vox companion, same path as the HUD dock) ──────────── + // Routes to HudVoxService — the local Vox transcription daemon — via the + // shared ScoutDictationController decision table, exactly like + // HUDDockState.toggleDictation(). No macOS system dictation, no extra + // mic-usage prompt (capture lives in the Vox process). + private func toggleDictation() { + focus = .composer + Task { + switch ScoutDictationController.toggleDecision(for: vox.state) { + case .probeThenStartIfIdle: + await vox.probe() + if case .idle = vox.state { vox.start() } + case .start: + vox.start() + case .stop: + vox.stop() + case .ignore: + break + } + } + } + + private var isDictating: Bool { + switch vox.state { + case .starting, .recording, .processing: return true + default: return false + } + } + + private var micSymbol: String { + switch vox.state { + case .recording: return "stop.fill" + case .starting, .processing: return "waveform" + default: return "mic.fill" + } + } + + private var voxStatusLine: String { + if !vox.partial.isEmpty { return vox.partial } + switch vox.state { + case .starting: return "Starting Vox…" + case .processing: return "Transcribing…" + default: return "Listening…" + } + } + + private var voxUnavailableReason: String? { + if case .unavailable(let reason) = vox.state { return reason } + return nil + } + + // Splice a finalized transcript into the current channel's draft, then + // drain lastFinalText so it isn't re-applied. Mirrors the dock's append. + private func spliceDictatedFinal(_ text: String) { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, let cId = service.selectedCId else { return } + drafts[cId] = ScoutDictationBuffer.appending(trimmed, to: drafts[cId] ?? "") + HudVoxService.shared.consumeFinalText() + focus = .composer + } + + private func copyCId() { + guard let cId = service.selectedCId else { return } + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(cId, forType: .string) + } + + private func openObserve(_ target: CommsObserveTarget) { + if observeTarget == target { + closeObserve() + return + } + observeTarget = target + CommsWindowController.shared.showObserve(target) + focus = .composer + } + + private func closeObserve() { + observeTarget = nil + CommsWindowController.shared.closeObserve() + focus = .composer + } + + private func observeTarget(for item: CommsItem) -> CommsObserveTarget? { + let agentId = item.agentId ?? item.participantIds.first { participant in + participant != "operator" && !participant.isEmpty + } + guard let agentId, !agentId.isEmpty else { return nil } + return CommsObserveTarget( + agentId: agentId, + title: item.agentName?.nilIfEmpty ?? item.displayTitle + ) + } + + private func observeTarget(for message: CommsMessage) -> CommsObserveTarget? { + guard !message.isOperator, + let actorId = message.actorId, + !actorId.isEmpty + else { return nil } + return CommsObserveTarget(agentId: actorId, title: message.actorName) + } + + private var selectedObserveTarget: CommsObserveTarget? { + service.selectedItem.flatMap(observeTarget(for:)) + } + + // ── Command area ──────────────────────────────────────────────────── + // A local command surface anchored over the chat area. Every entry acts + // on the Comms UI (navigate, scope, refresh, dictate, copy) — none of it + // transmits text to agents, which is why "/" can safely open it. + private func openCommands() { + commandQuery = "" + commandIndex = 0 + showCommands = true + focus = .command + } + + private func closeCommands() { + showCommands = false + focus = .composer + } + + private func runCommand(_ command: CommsCommand) { + closeCommands() + command.run() + } + + private var commands: [CommsCommand] { + var next = [ + CommsCommand(id: "jump", title: "Jump to channel", hint: "⌘K", systemImage: "magnifyingglass", keywords: ["search", "find", "goto"]) { focus = .search }, + CommsCommand(id: "next", title: "Next channel", hint: "⌘↓", systemImage: "chevron.down", keywords: ["down"]) { moveSelection(1) }, + CommsCommand(id: "prev", title: "Previous channel", hint: "⌘↑", systemImage: "chevron.up", keywords: ["up"]) { moveSelection(-1) }, + CommsCommand(id: "all", title: "Show all channels", hint: "⌘1", systemImage: "tray.full", keywords: ["filter"]) { service.filter = .all }, + CommsCommand(id: "private", title: "Show private", hint: "⌘2", systemImage: "lock", keywords: ["filter", "dm", "direct"]) { service.filter = .private }, + CommsCommand(id: "shared", title: "Show shared", hint: "⌘3", systemImage: "person.2", keywords: ["filter", "group"]) { service.filter = .shared }, + CommsCommand(id: "refresh", title: "Refresh", hint: "⌘R", systemImage: "arrow.clockwise", keywords: ["reload", "sync"]) { + service.refresh(force: true) + service.loadMessages() + }, + CommsCommand(id: "dictate", title: "Dictate message", hint: "", systemImage: "mic", keywords: ["voice", "speak", "vox"]) { toggleDictation() }, + CommsCommand(id: "copy", title: "Copy cId", hint: "", systemImage: "doc.on.doc", keywords: ["clipboard", "id"]) { copyCId() }, + ] + if let target = selectedObserveTarget { + next.insert( + CommsCommand(id: "observe", title: "Observe agent", hint: "⌘O", systemImage: "eye", keywords: ["peek", "work", "agent", "trace"]) { + openObserve(target) + }, + at: 3 + ) + } + return next + } + + private var filteredCommands: [CommsCommand] { + let query = commandQuery.trimmingCharacters(in: .whitespaces).lowercased() + guard !query.isEmpty else { return commands } + return commands.filter { + $0.title.lowercased().contains(query) || $0.keywords.contains { $0.contains(query) } + } + } + + private var commandPalette: some View { + VStack(spacing: 0) { + HStack(spacing: 8) { + Text("›") + .font(HUDType.mono(14, weight: .bold)) + .foregroundStyle(HUDChrome.accent) + TextField("Run a command", text: $commandQuery) + .textFieldStyle(.plain) + .font(HUDType.body(13)) + .foregroundStyle(HUDChrome.ink) + .focused($focus, equals: .command) + .onSubmit { + if let command = filteredCommands[safe: commandIndex] { + runCommand(command) + } + } + Text("ESC") + .font(HUDType.mono(9, weight: .bold)) + .foregroundStyle(HUDChrome.inkDeep) + } + .padding(.horizontal, 12) + .frame(height: 40) + + HUDHairline() + + if filteredCommands.isEmpty { + Text("NO COMMANDS") + .font(HUDType.mono(10, weight: .bold)) + .tracking(HUDType.eyebrowTracking) + .foregroundStyle(HUDChrome.inkMuted) + .frame(maxWidth: .infinity) + .frame(height: 56) + } else { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(Array(filteredCommands.enumerated()), id: \.element.id) { index, command in + Button { + runCommand(command) + } label: { + HStack(spacing: 10) { + Image(systemName: command.systemImage) + .font(.system(size: 12, weight: .semibold)) + .frame(width: 16) + .foregroundStyle(index == commandIndex ? HUDChrome.accent : HUDChrome.inkMuted) + Text(command.title) + .font(HUDType.body(13)) + .foregroundStyle(HUDChrome.ink) + Spacer() + if !command.hint.isEmpty { + Text(command.hint) + .font(HUDType.mono(9, weight: .bold)) + .foregroundStyle(HUDChrome.inkFaint) + } + } + .padding(.horizontal, 12) + .frame(height: 34) + .background(index == commandIndex ? HUDChrome.canvasLift : Color.clear) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onHover { hovering in + if hovering { commandIndex = index } + } + } + } + } + .frame(maxHeight: 240) + .scrollIndicators(.hidden) + } + } + .frame(width: 360) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(HUDChrome.canvasAlt) + ) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(HUDChrome.borderRim, lineWidth: 1) + ) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + .shadow(color: Color.black.opacity(0.45), radius: 24, y: 12) + .padding(.bottom, 14) + .onChange(of: commandQuery) { _, _ in commandIndex = 0 } + .onKeyPress(.upArrow) { + commandIndex = max(0, commandIndex - 1) + return .handled + } + .onKeyPress(.downArrow) { + commandIndex = min(filteredCommands.count - 1, commandIndex + 1) + return .handled + } + .onKeyPress(.escape) { + closeCommands() + return .handled + } + } + + // Invisible buttons that register window-level shortcuts. Kept active + // (opacity 0, not .hidden/.disabled) so the chords stay live. + private var keyboardCommands: some View { + Group { + Button("") { service.filter = .all }.keyboardShortcut("1", modifiers: .command) + Button("") { service.filter = .private }.keyboardShortcut("2", modifiers: .command) + Button("") { service.filter = .shared }.keyboardShortcut("3", modifiers: .command) + Button("") { moveSelection(1) }.keyboardShortcut(.downArrow, modifiers: .command) + Button("") { moveSelection(-1) }.keyboardShortcut(.upArrow, modifiers: .command) + Button("") { + service.refresh(force: true) + service.loadMessages() + }.keyboardShortcut("r", modifiers: .command) + Button("") { focus = .composer }.keyboardShortcut("l", modifiers: .command) + Button("") { focus = .search }.keyboardShortcut("k", modifiers: .command) + Button("") { showCommands ? closeCommands() : openCommands() }.keyboardShortcut("p", modifiers: .command) + Button("") { + if let target = selectedObserveTarget { openObserve(target) } + }.keyboardShortcut("o", modifiers: .command) + } + .opacity(0) + .frame(width: 0, height: 0) + .accessibilityHidden(true) + } + + private var composerPlaceholder: String { + if let title = service.selectedItem?.displayTitle, !title.isEmpty { + return "Message \(title)" + } + return "Message" + } + + private static func showsHeader(at index: Int, in messages: [CommsMessage]) -> Bool { + guard index > 0 else { return true } + let prev = messages[index - 1] + let current = messages[index] + if prev.actorId != current.actorId || prev.isOperator != current.isOperator { return true } + return normalizedSeconds(current.createdAt) - normalizedSeconds(prev.createdAt) > 300 + } + + private static func normalizedSeconds(_ timestamp: TimeInterval) -> TimeInterval { + timestamp > 10_000_000_000 ? timestamp / 1000 : timestamp + } +} + +private struct CommsFilterButton: View { + let label: String + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + Text(label.uppercased()) + .font(HUDType.mono(10, weight: .bold)) + .tracking(1.0) + .foregroundStyle(isSelected ? HUDChrome.canvas : HUDChrome.inkMuted) + .frame(minWidth: 70, minHeight: 28) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(isSelected ? HUDChrome.accent : HUDChrome.canvasLift) + ) + .overlay( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .stroke(isSelected ? HUDChrome.accentDim : HUDChrome.border, lineWidth: 1) + ) + } + .buttonStyle(.plain) + } +} + +private struct CommsRailRow: View { + let item: CommsItem + let isSelected: Bool + let action: () -> Void + + @State private var hovering = false + + var body: some View { + Button(action: action) { + VStack(alignment: .leading, spacing: 7) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(item.scopeLabel.uppercased()) + .font(HUDType.mono(8, weight: .bold)) + .tracking(1.2) + .foregroundStyle(isSelected ? HUDChrome.accent : HUDChrome.inkFaint) + Spacer(minLength: 8) + Text(item.lastMessageAt.map(formatShortTime) ?? "NEW") + .font(HUDType.mono(9)) + .foregroundStyle(HUDChrome.inkFaint) + } + + Text(item.displayTitle) + .font(HUDType.body(14, weight: .semibold)) + .foregroundStyle(HUDChrome.ink) + .lineLimit(1) + .truncationMode(.tail) + + HStack(spacing: 6) { + Text(item.preview?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty ?? item.cIdShort) + .font(HUDType.body(11)) + .foregroundStyle(HUDChrome.inkMuted) + .lineLimit(1) + .truncationMode(.tail) + Spacer(minLength: 8) + if item.messageCount > 0 { + Text("\(item.messageCount)") + .font(HUDType.mono(9, weight: .bold)) + .foregroundStyle(HUDChrome.inkFaint) + } + } + } + .padding(.horizontal, 14) + .padding(.vertical, 11) + .frame(maxWidth: .infinity, minHeight: 82, alignment: .leading) + .background(rowFill) + .overlay(alignment: .leading) { + Rectangle() + .fill(HUDChrome.accent) + .frame(width: 2.5) + .opacity(isSelected ? 1 : 0) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onHover { next in + hovering = next + } + + HUDHairline() + } + + private var rowFill: Color { + if isSelected { return HUDChrome.canvasLift } + if hovering { return HUDChrome.canvasAlt } + return HUDChrome.canvas + } +} + +private struct CommsMessageRow: View { + let message: CommsMessage + var showsHeader: Bool = true + var observeTarget: CommsObserveTarget? + var onObserve: (CommsObserveTarget) -> Void = { _ in } + + @State private var rowWidth: CGFloat = 0 + + var body: some View { + HStack(alignment: .bottom, spacing: 10) { + if message.isOperator { + Spacer(minLength: 54) + } + + VStack(alignment: message.isOperator ? .trailing : .leading, spacing: 5) { + if showsHeader { + HStack(spacing: 8) { + Text(message.actorName.uppercased()) + .font(HUDType.mono(9, weight: .bold)) + .tracking(1.0) + .foregroundStyle(message.isOperator ? HUDChrome.accent : HUDChrome.inkMuted) + Text(formatShortTime(message.createdAt)) + .font(HUDType.mono(9)) + .foregroundStyle(HUDChrome.inkFaint) + if let observeTarget { + Button { + onObserve(observeTarget) + } label: { + Image(systemName: "eye") + .font(.system(size: 9, weight: .semibold)) + .frame(width: 18, height: 18) + } + .buttonStyle(CommsTinyIconButtonStyle()) + .help("Observe \(observeTarget.title)") + } + } + } + + CommsMessageMarkup(text: message.body) + .padding(.horizontal, 12) + .padding(.vertical, 11) + .background( + RoundedRectangle(cornerRadius: 7, style: .continuous) + .fill(message.isOperator ? HUDChrome.accentWhisper : HUDChrome.canvasAlt) + ) + .overlay( + RoundedRectangle(cornerRadius: 7, style: .continuous) + .stroke(message.isOperator ? HUDChrome.accentSoft : HUDChrome.borderSoft, lineWidth: 1) + ) + } + .frame(maxWidth: bubbleMaxWidth, alignment: message.isOperator ? .trailing : .leading) + + if !message.isOperator { + Spacer(minLength: 54) + } + } + .frame(maxWidth: .infinity, alignment: message.isOperator ? .trailing : .leading) + .background( + GeometryReader { proxy in + Color.clear.preference(key: CommsMessageRowWidthKey.self, value: proxy.size.width) + } + ) + .onPreferenceChange(CommsMessageRowWidthKey.self) { width in + rowWidth = width + } + } + + private var bubbleMaxWidth: CGFloat { + guard rowWidth > 0 else { return 560 } + let readableWidth = max(560, (rowWidth - 108) * 0.78) + return min(920, readableWidth) + } +} + +private struct CommsMessageRowWidthKey: PreferenceKey { + static let defaultValue: CGFloat = 0 + + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = nextValue() + } +} + +private struct CommsChip: View { + let text: String + + var body: some View { + Text(text) + .font(HUDType.mono(9, weight: .bold)) + .tracking(0.8) + .foregroundStyle(HUDChrome.inkMuted) + .lineLimit(1) + .truncationMode(.middle) + .padding(.horizontal, 8) + .frame(height: 22) + .background( + RoundedRectangle(cornerRadius: 5, style: .continuous) + .fill(HUDChrome.canvasLift) + ) + .overlay( + RoundedRectangle(cornerRadius: 5, style: .continuous) + .stroke(HUDChrome.border, lineWidth: 1) + ) + } +} + +private struct CommsMemberStrip: View { + let item: CommsItem + + private var names: [String] { item.participantDisplayNames } + + var body: some View { + HStack(spacing: 7) { + HStack(spacing: -5) { + ForEach(Array(names.prefix(4).enumerated()), id: \.offset) { index, name in + CommsMemberAvatar(name: name, index: index) + } + if names.count > 4 { + Text("+\(names.count - 4)") + .font(HUDType.mono(8, weight: .bold)) + .foregroundStyle(HUDChrome.inkMuted) + .frame(width: 20, height: 20) + .background( + Circle() + .fill(HUDChrome.canvasLift) + ) + .overlay( + Circle() + .stroke(HUDChrome.border, lineWidth: 1) + ) + } + } + + Text(names.joined(separator: " + ")) + .font(HUDType.body(11, weight: .medium)) + .foregroundStyle(HUDChrome.inkMuted) + .lineLimit(1) + .truncationMode(.tail) + .frame(maxWidth: 280, alignment: .leading) + } + .padding(.leading, 3) + .padding(.trailing, 8) + .frame(height: 22) + .background( + RoundedRectangle(cornerRadius: 5, style: .continuous) + .fill(HUDChrome.canvasLift) + ) + .overlay( + RoundedRectangle(cornerRadius: 5, style: .continuous) + .stroke(HUDChrome.border, lineWidth: 1) + ) + .help("\(names.count) member\(names.count == 1 ? "" : "s"): \(names.joined(separator: ", "))") + } +} + +private struct CommsMemberAvatar: View { + let name: String + let index: Int + + var body: some View { + Text(initial) + .font(HUDType.mono(8, weight: .bold)) + .foregroundStyle(HUDChrome.canvas) + .frame(width: 20, height: 20) + .background( + Circle() + .fill(color) + ) + .overlay( + Circle() + .stroke(HUDChrome.canvasLift, lineWidth: 1.5) + ) + .zIndex(Double(8 - index)) + } + + private var initial: String { + name.trimmingCharacters(in: .whitespacesAndNewlines).first.map { String($0).uppercased() } ?? "?" + } + + private var color: Color { + if name.lowercased() == "operator" { + return HUDChrome.accent + } + return HUDChrome.agentHue(Double(stableHueSeed(for: name)), lightness: 0.78, saturation: 0.58) + } + + private func stableHueSeed(for text: String) -> Int { + var hash: UInt64 = 5381 + for byte in text.lowercased().utf8 { + hash = (hash &* 33) &+ UInt64(byte) + } + return Int(hash % 360) + } +} + +private extension CommsItem { + var participantDisplayNames: [String] { + if scope == .private { + let peer = agentName?.nilIfEmpty + ?? participantIds.first(where: { displayName(for: $0) != "Operator" }).map(displayName(for:)) + ?? displayTitle + return uniqueMemberNames(["Operator", peer]) + } + + var names: [String] = [] + for participant in participantIds { + let name = displayName(for: participant) + if !names.contains(name) { + names.append(name) + } + } + + if names.isEmpty { + names.append(displayTitle) + } + + return uniqueMemberNames(names) + } + + private func displayName(for participant: String) -> String { + let trimmed = participant.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return "Unknown" } + if trimmed == "operator" { return "Operator" } + if trimmed == agentId, let agentName = agentName?.nilIfEmpty { return agentName } + if let agentName = agentName?.nilIfEmpty, + trimmed.lowercased().contains(agentName.lowercased()) { + return agentName + } + + let withoutHandle = trimmed.trimmingCharacters(in: CharacterSet(charactersIn: "@")) + let compact = withoutHandle.split(separator: ".").first.map(String.init) ?? withoutHandle + return compact + .replacingOccurrences(of: "-", with: " ") + .split(separator: " ") + .map { part in + guard let first = part.first else { return "" } + return first.uppercased() + part.dropFirst() + } + .joined(separator: " ") + } + + private func uniqueMemberNames(_ names: [String]) -> [String] { + var result: [String] = [] + for name in names { + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + if !result.contains(where: { $0.caseInsensitiveCompare(trimmed) == .orderedSame }) { + result.append(trimmed) + } + } + return result + } +} + +private struct CommsIconButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .foregroundStyle(HUDChrome.inkMuted) + .frame(width: 28, height: 28) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(configuration.isPressed ? HUDChrome.canvasLift : HUDChrome.canvas) + ) + .overlay( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .stroke(HUDChrome.border, lineWidth: 1) + ) + } +} + +private struct CommsTinyIconButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .foregroundStyle(configuration.isPressed ? HUDChrome.accent : HUDChrome.inkFaint) + .background( + RoundedRectangle(cornerRadius: 4, style: .continuous) + .fill(configuration.isPressed ? HUDChrome.canvasLift : HUDChrome.canvasAlt) + ) + .overlay( + RoundedRectangle(cornerRadius: 4, style: .continuous) + .stroke(HUDChrome.borderSoft, lineWidth: 1) + ) + } +} + +private struct CommsSendButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .foregroundStyle(HUDChrome.canvas) + .background( + RoundedRectangle(cornerRadius: 7, style: .continuous) + .fill(configuration.isPressed ? HUDChrome.accentDim : HUDChrome.accent) + ) + .scaleEffect(configuration.isPressed ? 0.97 : 1) + } +} + +private struct CommsCommand: Identifiable { + let id: String + let title: String + let hint: String + let systemImage: String + let keywords: [String] + let run: () -> Void +} + +private struct CommsObserveTarget: Identifiable, Equatable { + let agentId: String + let title: String + + var id: String { agentId } + + var url: URL { + HudFleetService.webBaseURL() + .appending(path: "embed") + .appending(path: "observe") + .appending(path: agentId) + } +} + +private struct CommsObserveSidecarView: View { + let target: CommsObserveTarget + let onClose: () -> Void + let onReady: () -> Void + + @State private var reloadToken = UUID() + @State private var isReady = false + + var body: some View { + ZStack { + VisualEffectBackground(material: .hudWindow, cornerRadius: 8) + HUDChrome.canvas + HUDPaperGrain(opacity: 0.03) + + VStack(spacing: 0) { + header + HUDHairline() + CommsObserveWebView( + url: target.url, + reloadToken: reloadToken, + onReady: handleReady + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(HUDChrome.canvas) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .opacity(isReady ? 1 : 0.001) + + if !isReady { + CommsObserveMaterializingView() + .transition(.opacity) + } + } + .overlay(alignment: .trailing) { + if isReady { + CommsObserveResizeHandle() + .transition(.opacity) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .animation(.easeOut(duration: 0.12), value: isReady) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(HUDChrome.borderRim, lineWidth: 1) + .allowsHitTesting(false) + ) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + .preferredColorScheme(.dark) + } + + private var header: some View { + HStack(spacing: 10) { + HStack(spacing: 10) { + Image(systemName: "eye") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(HUDChrome.accent) + .frame(width: 26, height: 26) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(HUDChrome.accentWhisper) + ) + .overlay( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .stroke(HUDChrome.accentSoft, lineWidth: 1) + ) + + VStack(alignment: .leading, spacing: 2) { + Text("OBSERVE") + .font(HUDType.mono(10, weight: .bold)) + .tracking(HUDType.eyebrowTracking) + .foregroundStyle(HUDChrome.inkMuted) + Text(target.title) + .font(HUDType.body(13, weight: .semibold)) + .foregroundStyle(HUDChrome.ink) + .lineLimit(1) + .truncationMode(.tail) + } + + Spacer(minLength: 8) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + .contentShape(Rectangle()) + .overlay { + CommsObserveFamilyDragCapture() + } + + Button { + isReady = false + reloadToken = UUID() + } label: { + Image(systemName: "arrow.clockwise") + .font(.system(size: 11, weight: .semibold)) + } + .buttonStyle(CommsIconButtonStyle()) + .help("Reload observe") + + Button(action: onClose) { + Image(systemName: "sidebar.right") + .font(.system(size: 11, weight: .semibold)) + } + .buttonStyle(CommsIconButtonStyle()) + .help("Close observe") + } + .padding(.horizontal, 14) + .frame(height: 58) + .background(HUDChrome.canvasAlt) + } + + private func handleReady() { + guard !isReady else { return } + isReady = true + onReady() + } + +} + +private struct CommsObserveFamilyDragCapture: NSViewRepresentable { + func makeNSView(context: Context) -> CommsObserveFamilyDragCaptureView { + CommsObserveFamilyDragCaptureView() + } + + func updateNSView(_ nsView: CommsObserveFamilyDragCaptureView, context: Context) {} +} + +@MainActor +private final class CommsObserveFamilyDragCaptureView: NSView { + private var dragStartMouseLocation = NSPoint.zero + + override var acceptsFirstResponder: Bool { true } + override var mouseDownCanMoveWindow: Bool { false } + + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { + true + } + + override func mouseDown(with event: NSEvent) { + window?.makeFirstResponder(self) + dragStartMouseLocation = NSEvent.mouseLocation + CommsWindowController.shared.beginFamilyDrag() + } + + override func mouseDragged(with event: NSEvent) { + let current = NSEvent.mouseLocation + CommsWindowController.shared.dragFamily( + screenTranslation: CGSize( + width: current.x - dragStartMouseLocation.x, + height: current.y - dragStartMouseLocation.y + ) + ) + } + + override func mouseUp(with event: NSEvent) { + CommsWindowController.shared.endFamilyDrag() + } +} + +private struct CommsObserveResizeHandle: View { + @State private var isHovering = false + + var body: some View { + ZStack(alignment: .trailing) { + CommsObserveResizeCapture(isHovering: $isHovering) + .frame(width: 34) + + Capsule(style: .continuous) + .fill(isHovering ? HUDChrome.accentSoft : HUDChrome.borderSoft) + .frame(width: isHovering ? 3 : 2, height: isHovering ? 64 : 42) + .padding(.trailing, 5) + .opacity(isHovering ? 0.95 : 0.42) + .allowsHitTesting(false) + } + .frame(width: 34) + .help("Resize observe") + } +} + +private struct CommsObserveResizeCapture: NSViewRepresentable { + @Binding var isHovering: Bool + + func makeNSView(context: Context) -> CommsObserveResizeCaptureView { + let view = CommsObserveResizeCaptureView() + view.onHover = { hovering in + isHovering = hovering + } + return view + } + + func updateNSView(_ nsView: CommsObserveResizeCaptureView, context: Context) { + nsView.onHover = { hovering in + isHovering = hovering + } + } +} + +@MainActor +private final class CommsObserveResizeCaptureView: NSView { + var onHover: ((Bool) -> Void)? + private var trackingAreaRef: NSTrackingArea? + private var dragTranslation: CGFloat = 0 + + override var acceptsFirstResponder: Bool { true } + override var mouseDownCanMoveWindow: Bool { false } + + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { + true + } + + override func updateTrackingAreas() { + super.updateTrackingAreas() + if let trackingAreaRef { + removeTrackingArea(trackingAreaRef) + } + let trackingArea = NSTrackingArea( + rect: bounds, + options: [.activeAlways, .mouseEnteredAndExited, .inVisibleRect], + owner: self, + userInfo: nil + ) + trackingAreaRef = trackingArea + addTrackingArea(trackingArea) + } + + override func resetCursorRects() { + super.resetCursorRects() + addCursorRect(bounds, cursor: .resizeLeftRight) + } + + override func mouseEntered(with event: NSEvent) { + onHover?(true) + NSCursor.resizeLeftRight.set() + } + + override func mouseExited(with event: NSEvent) { + onHover?(false) + NSCursor.arrow.set() + } + + override func mouseDown(with event: NSEvent) { + window?.makeFirstResponder(self) + dragTranslation = 0 + CommsWindowController.shared.beginObserveResize() + onHover?(true) + NSCursor.resizeLeftRight.set() + } + + override func mouseDragged(with event: NSEvent) { + dragTranslation += event.deltaX + CommsWindowController.shared.resizeObserveWindow(translationWidth: dragTranslation) + } + + override func mouseUp(with event: NSEvent) { + CommsWindowController.shared.endObserveResize() + dragTranslation = 0 + onHover?(bounds.contains(convert(event.locationInWindow, from: nil))) + NSCursor.resizeLeftRight.set() + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + window?.invalidateCursorRects(for: self) + } + + deinit { + NSCursor.arrow.set() + } +} + +private struct CommsObserveMaterializingView: View { + var body: some View { + TimelineView(.animation) { context in + let phase = context.date.timeIntervalSinceReferenceDate + VStack(spacing: 12) { + ZStack { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(HUDChrome.accentWhisper) + .frame(width: 38, height: 38) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(HUDChrome.accentSoft, lineWidth: 1) + ) + + Image(systemName: "eye.fill") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(HUDChrome.accent) + .opacity(0.72 + 0.18 * sin(phase * 8.0)) + } + + PixelDither(phase: phase) + .frame(width: 34, height: 22) + + Text("OBSERVE") + .font(HUDType.mono(8, weight: .bold)) + .tracking(1.2) + .foregroundStyle(HUDChrome.inkFaint) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(HUDChrome.canvas.opacity(0.96)) + } + } +} + +private struct PixelDither: View { + let phase: TimeInterval + + var body: some View { + Grid(horizontalSpacing: 3, verticalSpacing: 3) { + ForEach(0..<3, id: \.self) { row in + GridRow { + ForEach(0..<5, id: \.self) { column in + let offset = Double(row * 5 + column) + Rectangle() + .fill(HUDChrome.accent) + .frame(width: 4, height: 4) + .opacity(0.18 + 0.72 * pulse(offset)) + } + } + } + } + } + + private func pulse(_ offset: Double) -> Double { + let wave = sin(phase * 7.0 - offset * 0.55) + return max(0.0, min(1.0, (wave + 1.0) / 2.0)) + } +} + +private struct CommsObserveWebView: NSViewRepresentable { + let url: URL + let reloadToken: UUID + let onReady: () -> Void + + func makeCoordinator() -> Coordinator { + Coordinator(onReady: onReady) + } + + func makeNSView(context: Context) -> WKWebView { + let configuration = WKWebViewConfiguration() + configuration.defaultWebpagePreferences.allowsContentJavaScript = true + let webView = WKWebView(frame: .zero, configuration: configuration) + webView.allowsBackForwardNavigationGestures = true + webView.navigationDelegate = context.coordinator + webView.setValue(false, forKey: "drawsBackground") + if #available(macOS 13.3, *) { + webView.isInspectable = true + } + return webView + } + + func updateNSView(_ webView: WKWebView, context: Context) { + guard context.coordinator.currentURL != url || context.coordinator.reloadToken != reloadToken else { + return + } + context.coordinator.currentURL = url + context.coordinator.reloadToken = reloadToken + context.coordinator.readyURL = nil + context.coordinator.navigationStartedAt = Date() + context.coordinator.navigationToken = UUID() + webView.load(URLRequest(url: url)) + } + + final class Coordinator: NSObject, WKNavigationDelegate { + private let minimumLoaderDwell: TimeInterval = 0.42 + private let maximumRenderWait: TimeInterval = 2.0 + private let renderPollInterval: TimeInterval = 0.05 + + let onReady: () -> Void + var currentURL: URL? + var reloadToken: UUID? + var readyURL: URL? + var navigationStartedAt = Date.distantPast + var navigationToken = UUID() + + init(onReady: @escaping () -> Void) { + self.onReady = onReady + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + guard let currentURL, readyURL != currentURL else { return } + waitForObserveRender(in: webView, url: currentURL, token: navigationToken) + } + + private func waitForObserveRender(in webView: WKWebView, url: URL, token: UUID) { + guard token == navigationToken, readyURL != url else { return } + + let script = """ + (() => { + const title = document.querySelector('.s-observe-embed-empty-title')?.textContent || ''; + const resolving = title.includes('Resolving'); + const timeline = Boolean(document.querySelector('.s-observe-stream')); + const terminal = Boolean(document.querySelector('.s-observe-embed-empty')) && !resolving; + const bodyText = document.body?.innerText || ''; + return { + ready: (timeline || terminal) && !resolving, + hasText: bodyText.trim().length > 0 + }; + })() + """ + + webView.evaluateJavaScript(script) { result, _ in + DispatchQueue.main.async { + guard token == self.navigationToken, self.readyURL != url else { return } + let elapsed = Date().timeIntervalSince(self.navigationStartedAt) + let payload = result as? [String: Any] + let rendered = payload?["ready"] as? Bool ?? false + let hasText = payload?["hasText"] as? Bool ?? false + let canReveal = rendered && hasText && elapsed >= self.minimumLoaderDwell + if canReveal || elapsed >= self.maximumRenderWait { + self.markReady(url) + return + } + + DispatchQueue.main.asyncAfter(deadline: .now() + self.renderPollInterval) { + self.waitForObserveRender(in: webView, url: url, token: token) + } + } + } + } + + private func markReady(_ url: URL) { + guard readyURL != url else { return } + readyURL = url + // Give React/WebKit one final paint beat after the DOM says the + // observe surface exists, so the sidecar expands with content in it. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + self.onReady() + } + } + } +} + +private struct CommsPillButtonStyle: ButtonStyle { + let isActive: Bool + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .foregroundStyle(isActive ? HUDChrome.canvas : HUDChrome.inkMuted) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(isActive ? HUDChrome.accent : HUDChrome.canvasLift) + ) + .overlay( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .stroke(isActive ? HUDChrome.accentDim : HUDChrome.border, lineWidth: 1) + ) + .scaleEffect(configuration.isPressed ? 0.98 : 1) + } +} + +private extension Array { + subscript(safe index: Int) -> Element? { + indices.contains(index) ? self[index] : nil + } +} + +private func formatShortTime(_ timestamp: TimeInterval) -> String { + let seconds = timestamp > 10_000_000_000 ? timestamp / 1000 : timestamp + let date = Date(timeIntervalSince1970: seconds) + if Calendar.current.isDateInToday(date) { + return SelfTimeFormatter.time.string(from: date) + } + return SelfTimeFormatter.date.string(from: date) +} + +private enum SelfTimeFormatter { + static let time: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "h:mm a" + return formatter + }() + + static let date: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "MMM d" + return formatter + }() +} + +private extension String { + var nilIfEmpty: String? { + isEmpty ? nil : self + } +} diff --git a/apps/macos/Sources/Views/Glyphs.swift b/apps/macos/Sources/OpenScoutMenu/Views/Glyphs.swift similarity index 100% rename from apps/macos/Sources/Views/Glyphs.swift rename to apps/macos/Sources/OpenScoutMenu/Views/Glyphs.swift diff --git a/apps/macos/Sources/Views/MainView.swift b/apps/macos/Sources/OpenScoutMenu/Views/MainView.swift similarity index 98% rename from apps/macos/Sources/Views/MainView.swift rename to apps/macos/Sources/OpenScoutMenu/Views/MainView.swift index 86ed7e08..c68de27f 100644 --- a/apps/macos/Sources/Views/MainView.swift +++ b/apps/macos/Sources/OpenScoutMenu/Views/MainView.swift @@ -118,6 +118,16 @@ struct MainView: View { .buttonStyle(HeaderIconButtonStyle()) .help(showQR ? "Hide pairing QR" : "Show pairing QR") + Button { + controller.openComms() + } label: { + Image(systemName: "bubble.left.and.bubble.right") + .font(.system(size: 11, weight: .semibold)) + } + .buttonStyle(HeaderIconButtonStyle()) + .disabled(controller.webActionPending) + .help("Open Comms") + Button { controller.openWebApp() } label: { diff --git a/apps/macos/Sources/Views/ServiceLightRow.swift b/apps/macos/Sources/OpenScoutMenu/Views/ServiceLightRow.swift similarity index 100% rename from apps/macos/Sources/Views/ServiceLightRow.swift rename to apps/macos/Sources/OpenScoutMenu/Views/ServiceLightRow.swift diff --git a/apps/macos/Sources/Views/SettingsWindow.swift b/apps/macos/Sources/OpenScoutMenu/Views/SettingsWindow.swift similarity index 100% rename from apps/macos/Sources/Views/SettingsWindow.swift rename to apps/macos/Sources/OpenScoutMenu/Views/SettingsWindow.swift diff --git a/apps/macos/Sources/Views/Theme.swift b/apps/macos/Sources/OpenScoutMenu/Views/Theme.swift similarity index 100% rename from apps/macos/Sources/Views/Theme.swift rename to apps/macos/Sources/OpenScoutMenu/Views/Theme.swift diff --git a/apps/macos/Sources/Scout/ScoutApp.swift b/apps/macos/Sources/Scout/ScoutApp.swift new file mode 100644 index 00000000..f7ac6d12 --- /dev/null +++ b/apps/macos/Sources/Scout/ScoutApp.swift @@ -0,0 +1,32 @@ +import AppKit +import HudsonShell +import HudsonUI +import SwiftUI + +@main +struct ScoutApp: App { + @NSApplicationDelegateAdaptor(ScoutAppDelegate.self) private var delegate + + init() { + NSApplication.shared.setActivationPolicy(.regular) + NSApplication.shared.activate(ignoringOtherApps: true) + } + + var body: some Scene { + WindowGroup("Scout") { + ScoutRootView() + .frame(minWidth: 1040, minHeight: 680) + .preferredColorScheme(.dark) + } + .hudChromeWindow() + .commands { + CommandGroup(replacing: .newItem) {} + } + } +} + +final class ScoutAppDelegate: NSObject, NSApplicationDelegate { + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + true + } +} diff --git a/apps/macos/Sources/Scout/ScoutCommsStore.swift b/apps/macos/Sources/Scout/ScoutCommsStore.swift new file mode 100644 index 00000000..c2838308 --- /dev/null +++ b/apps/macos/Sources/Scout/ScoutCommsStore.swift @@ -0,0 +1,330 @@ +import Combine +import Foundation +#if os(macOS) +import AppKit +#endif + +@MainActor +final class ScoutCommsStore: ObservableObject { + @Published private(set) var channels: [ScoutChannel] = [] + @Published private(set) var messages: [ScoutMessage] = [] + @Published private(set) var agents: [ScoutAgent] = [] + @Published private(set) var selectedCId: String? + @Published var selectedAgentId: String? + @Published var channelQuery = "" + @Published var isLoading = false + @Published var isSending = false + @Published var lastError: String? + + private let decoder = JSONDecoder() + private var pollTask: Task? + private var channelsTask: Task? + private var messagesTask: Task? + private var agentsTask: Task? + + var selectedChannel: ScoutChannel? { + guard let selectedCId else { return nil } + return channels.first { $0.cId == selectedCId } + } + + var selectedAgent: ScoutAgent? { + if let selectedAgentId, + let direct = agents.first(where: { $0.id == selectedAgentId }) { + return direct + } + guard let channel = selectedChannel else { return nil } + if let agentId = channel.agentId, + let agent = agents.first(where: { $0.id == agentId }) { + return agent + } + if let agentName = channel.agentName?.nilIfEmpty { + return agents.first { + $0.name.caseInsensitiveCompare(agentName) == .orderedSame + || $0.id.localizedCaseInsensitiveContains(agentName) + } + } + return nil + } + + var visibleChannels: [ScoutChannel] { + let trimmed = channelQuery.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return channels } + return channels.filter { channel in + channel.displayTitle.localizedCaseInsensitiveContains(trimmed) + || channel.cId.localizedCaseInsensitiveContains(trimmed) + || channel.participantDisplayNames.joined(separator: " ").localizedCaseInsensitiveContains(trimmed) + } + } + + var activeAgentCount: Int { + agents.filter { $0.state == .working || $0.state == .needsAttention || $0.state == .available }.count + } + + func start() { + guard pollTask == nil else { + refresh(force: true) + return + } + refresh(force: true) + pollTask = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 2_500_000_000) + self?.refresh() + } + } + } + + func stop() { + pollTask?.cancel() + pollTask = nil + channelsTask?.cancel() + messagesTask?.cancel() + agentsTask?.cancel() + channelsTask = nil + messagesTask = nil + agentsTask = nil + } + + func refresh(force: Bool = false) { + loadChannels(force: force) + loadAgents(force: force) + } + + func selectChannel(_ cId: String) { + guard selectedCId != cId else { return } + selectedCId = cId + selectedAgentId = channels.first(where: { $0.cId == cId })?.agentId + messages = [] + loadMessages() + } + + func selectAgent(_ agentId: String) { + selectedAgentId = agentId + } + + func openAgentChannel(_ agent: ScoutAgent) { + selectedAgentId = agent.id + if let cId = agent.conversationId ?? channels.first(where: { $0.agentId == agent.id })?.cId { + selectedCId = cId + loadMessages() + } + } + + func loadMessages() { + guard let selectedCId else { return } + messagesTask?.cancel() + messagesTask = Task { [weak self] in + await self?.loadMessages(cId: selectedCId) + } + } + + func send(_ body: String) async { + let trimmed = body.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, let selectedCId, !isSending else { return } + isSending = true + defer { isSending = false } + + do { + let url = ScoutWeb.baseURL().appending(path: "api/send") + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONSerialization.data(withJSONObject: [ + "body": trimmed, + "cId": selectedCId, + "conversationId": selectedCId, + ]) + let (_, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { + throw ScoutCommsError.sendFailed + } + lastError = nil + refresh(force: true) + loadMessages() + } catch { + lastError = Self.userFacingError(error) + } + } + + private func loadChannels(force: Bool) { + if channelsTask != nil { return } + if !force, pollTask == nil { return } + isLoading = channels.isEmpty + channelsTask = Task { [weak self] in + await self?.fetchChannels() + } + } + + private func loadAgents(force: Bool) { + if agentsTask != nil { return } + if !force, pollTask == nil { return } + agentsTask = Task { [weak self] in + await self?.fetchAgents() + } + } + + private func fetchChannels() async { + defer { + isLoading = false + channelsTask = nil + } + + do { + let base = ScoutWeb.baseURL() + let commsURL = base + .appending(path: "api/comms") + .appending(queryItems: [URLQueryItem(name: "limit", value: "160")]) + let fallbackURL = base + .appending(path: "api/conversations") + .appending(queryItems: [URLQueryItem(name: "limit", value: "160")]) + let next = try await fetchWithFallback([ScoutChannel].self, primary: commsURL, fallback: fallbackURL) + channels = next + if selectedCId == nil || !next.contains(where: { $0.cId == selectedCId }) { + selectedCId = next.first?.cId + selectedAgentId = next.first?.agentId + } + lastError = nil + loadMessages() + } catch { + lastError = Self.userFacingError(error) + } + } + + private func fetchAgents() async { + defer { agentsTask = nil } + do { + agents = try await fetch([ScoutAgent].self, from: ScoutWeb.baseURL().appending(path: "api/agents")) + lastError = nil + } catch { + if channels.isEmpty { + lastError = Self.userFacingError(error) + } + } + } + + private func loadMessages(cId: String) async { + defer { messagesTask = nil } + do { + let url = ScoutWeb.baseURL() + .appending(path: "api/messages") + .appending(queryItems: [ + URLQueryItem(name: "cId", value: cId), + URLQueryItem(name: "conversationId", value: cId), + URLQueryItem(name: "limit", value: "260"), + ]) + let next = try await fetch([ScoutMessage].self, from: url) + guard selectedCId == cId else { return } + messages = next.sorted { $0.createdAt < $1.createdAt } + lastError = nil + } catch { + guard selectedCId == cId else { return } + lastError = Self.userFacingError(error) + } + } + + private func fetch(_ type: T.Type, from url: URL) async throws -> T { + let (data, response) = try await URLSession.shared.data(from: url) + guard let http = response as? HTTPURLResponse else { + throw ScoutCommsError.invalidResponse + } + guard (200..<300).contains(http.statusCode) else { + throw ScoutCommsError.httpStatus(http.statusCode) + } + return try decoder.decode(type, from: data) + } + + private func fetchWithFallback(_ type: T.Type, primary: URL, fallback: URL) async throws -> T { + do { + return try await fetch(type, from: primary) + } catch { + return try await fetch(type, from: fallback) + } + } + + private static func userFacingError(_ error: Error) -> String { + if let scoutError = error as? ScoutCommsError { + return scoutError.localizedDescription + } + return error.localizedDescription + } +} + +enum ScoutCommsError: LocalizedError { + case invalidResponse + case httpStatus(Int) + case sendFailed + + var errorDescription: String? { + switch self { + case .invalidResponse: + return "Scout returned an invalid response." + case .httpStatus(let status): + return "Scout returned HTTP \(status)." + case .sendFailed: + return "Scout send failed." + } + } +} + +enum ScoutWeb { + private static let fallbackURL = URL(string: "http://127.0.0.1:3200")! + + static func baseURL() -> URL { + if let url = readWebURLFromEnvironment() { + return url + } + if let url = readWebURLFromConfig() { + return url + } + return fallbackURL + } + + static func open(path: String) { + var normalized = path + if !normalized.hasPrefix("/") { + normalized = "/" + normalized + } + guard let url = URL(string: normalized, relativeTo: baseURL())?.absoluteURL else { return } + #if os(macOS) + NSWorkspace.shared.open(url) + #endif + } + + private static func readWebURLFromEnvironment() -> URL? { + let env = ProcessInfo.processInfo.environment + for key in ["OPENSCOUT_WEB_URL", "OPENSCOUT_WEB_BUN_URL", "OPENSCOUT_WEB_PUBLIC_ORIGIN"] { + guard let value = env[key]?.trimmingCharacters(in: .whitespacesAndNewlines), + !value.isEmpty, + let url = URL(string: value) else { + continue + } + return url + } + + let portValue = env["OPENSCOUT_WEB_PORT"] ?? env["SCOUT_WEB_PORT"] + guard let portText = portValue?.trimmingCharacters(in: .whitespacesAndNewlines), + let port = Int(portText), + (1...65_535).contains(port) else { + return nil + } + let rawHost = env["OPENSCOUT_WEB_HOST"]?.trimmingCharacters(in: .whitespacesAndNewlines) + let host = (rawHost?.isEmpty == false && rawHost != "0.0.0.0" && rawHost != "::") + ? rawHost! + : "127.0.0.1" + return URL(string: "http://\(host):\(port)") + } + + private static func readWebURLFromConfig() -> URL? { + struct OpenScoutConfig: Decodable { + struct Ports: Decodable { let web: Int? } + let host: String? + let ports: Ports? + } + let path = ("~/.openscout/config.json" as NSString).expandingTildeInPath + guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else { return nil } + guard let cfg = try? JSONDecoder().decode(OpenScoutConfig.self, from: data) else { return nil } + let host = cfg.host ?? "127.0.0.1" + guard let port = cfg.ports?.web else { return nil } + return URL(string: "http://\(host):\(port)") + } +} diff --git a/apps/macos/Sources/Scout/ScoutModels.swift b/apps/macos/Sources/Scout/ScoutModels.swift new file mode 100644 index 00000000..d855863a --- /dev/null +++ b/apps/macos/Sources/Scout/ScoutModels.swift @@ -0,0 +1,343 @@ +import Foundation +import SwiftUI + +enum ScoutSection: String, CaseIterable, Identifiable { + case comms + case agents + + var id: String { rawValue } + + var title: String { + switch self { + case .comms: return "Comms" + case .agents: return "Agents" + } + } + + var icon: String { + switch self { + case .comms: return "bubble.left.and.bubble.right" + case .agents: return "person.2" + } + } +} + +enum ScoutChannelScope { + case direct + case shared + + var label: String { + switch self { + case .direct: return "Private" + case .shared: return "Shared" + } + } +} + +struct ScoutChannel: Identifiable, Decodable, Sendable { + let cId: String + let kind: String + let title: String + let alias: String? + let participantIds: [String] + let agentId: String? + let agentName: String? + let harness: String? + let preview: String? + let messageCount: Int + let lastMessageAt: TimeInterval? + let workspaceRoot: String? + let currentBranch: String? + + var id: String { cId } + + var displayTitle: String { + alias?.nilIfEmpty ?? agentName?.nilIfEmpty ?? title + } + + var scope: ScoutChannelScope { + if kind == "direct", participantIds.count <= 2 { + return .direct + } + return .shared + } + + var cIdShort: String { + if cId.hasPrefix("c.") { + return "cId \(String(cId.dropFirst(2).prefix(8)))" + } + if cId.hasPrefix("dm.") { + return "cId legacy-dm" + } + if cId.hasPrefix("channel.") { + return "cId #\(String(cId.dropFirst("channel.".count)))" + } + return cId.count > 16 ? "cId \(String(cId.prefix(12)))" : "cId \(cId)" + } + + var participantDisplayNames: [String] { + if scope == .direct { + let peer = agentName?.nilIfEmpty + ?? participantIds.first(where: { displayName(for: $0) != "Operator" }).map(displayName(for:)) + ?? displayTitle + return uniqueMemberNames(["Operator", peer]) + } + + let names = participantIds.map(displayName(for:)) + return uniqueMemberNames(names.isEmpty ? [displayTitle] : names) + } + + var ageLabel: String { + ScoutRelativeTime.format(lastMessageAt) + } + + enum CodingKeys: String, CodingKey { + case cId + case fallbackId = "id" + case kind + case title + case alias + case participantIds + case agentId + case agentName + case harness + case preview + case messageCount + case lastMessageAt + case workspaceRoot + case currentBranch + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + cId = try c.decodeIfPresent(String.self, forKey: .cId) + ?? c.decode(String.self, forKey: .fallbackId) + kind = try c.decode(String.self, forKey: .kind) + title = try c.decode(String.self, forKey: .title) + alias = try c.decodeIfPresent(String.self, forKey: .alias) + participantIds = try c.decodeIfPresent([String].self, forKey: .participantIds) ?? [] + agentId = try c.decodeIfPresent(String.self, forKey: .agentId) + agentName = try c.decodeIfPresent(String.self, forKey: .agentName) + harness = try c.decodeIfPresent(String.self, forKey: .harness) + preview = try c.decodeIfPresent(String.self, forKey: .preview) + messageCount = try c.decodeIfPresent(Int.self, forKey: .messageCount) ?? 0 + lastMessageAt = try c.decodeIfPresent(TimeInterval.self, forKey: .lastMessageAt) + workspaceRoot = try c.decodeIfPresent(String.self, forKey: .workspaceRoot) + currentBranch = try c.decodeIfPresent(String.self, forKey: .currentBranch) + } + + private func displayName(for participant: String) -> String { + let trimmed = participant.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return "Unknown" } + if trimmed == "operator" { return "Operator" } + if trimmed == agentId, let agentName = agentName?.nilIfEmpty { return agentName } + if let agentName = agentName?.nilIfEmpty, + trimmed.lowercased().contains(agentName.lowercased()) { + return agentName + } + + let withoutHandle = trimmed.trimmingCharacters(in: CharacterSet(charactersIn: "@")) + let compact = withoutHandle.split(separator: ".").first.map(String.init) ?? withoutHandle + return compact + .replacingOccurrences(of: "-", with: " ") + .split(separator: " ") + .map { part in + guard let first = part.first else { return "" } + return first.uppercased() + part.dropFirst() + } + .joined(separator: " ") + } +} + +struct ScoutMessage: Identifiable, Decodable, Sendable { + let id: String + let cId: String + let actorId: String? + let actorName: String + let body: String + let createdAt: TimeInterval + let messageClass: String + + var isOperator: Bool { + actorId == "operator" || messageClass == "operator" || actorName.lowercased() == "operator" + } + + enum CodingKeys: String, CodingKey { + case id + case cId + case fallbackCId = "conversationId" + case actorId + case actorName + case body + case createdAt + case messageClass = "class" + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(String.self, forKey: .id) + cId = try c.decodeIfPresent(String.self, forKey: .cId) + ?? c.decode(String.self, forKey: .fallbackCId) + actorId = try c.decodeIfPresent(String.self, forKey: .actorId) + actorName = try c.decodeIfPresent(String.self, forKey: .actorName) + ?? actorId + ?? "unknown" + body = try c.decode(String.self, forKey: .body) + createdAt = try c.decode(TimeInterval.self, forKey: .createdAt) + messageClass = try c.decodeIfPresent(String.self, forKey: .messageClass) ?? "message" + } +} + +enum ScoutAgentState: String, Sendable, Decodable { + case working + case available + case offline + case done + case needsAttention = "needs-attention" + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + self = Self.from(raw: try? container.decode(String.self)) + } + + static func from(raw: String?) -> ScoutAgentState { + switch (raw ?? "offline").lowercased().replacingOccurrences(of: "_", with: "-") { + case "working", "running", "waking", "queued": + return .working + case "waiting", "blocked", "needs-attention", "needsattention", "on-you": + return .needsAttention + case "available", "idle", "ready": + return .available + case "done", "completed", "complete": + return .done + default: + return .offline + } + } + + var label: String { + switch self { + case .working: return "Working" + case .available: return "Available" + case .offline: return "Offline" + case .done: return "Done" + case .needsAttention: return "Needs attention" + } + } + + var tint: Color { + switch self { + case .working: return .green + case .available: return .cyan + case .offline: return .gray + case .done: return .blue + case .needsAttention: return .orange + } + } +} + +struct ScoutAgent: Identifiable, Decodable, Sendable { + let id: String + let name: String + let handle: String? + let harness: String? + let state: ScoutAgentState + let role: String? + let projectRoot: String? + let cwd: String? + let project: String? + let branch: String? + let selector: String? + let model: String? + let transport: String? + let capabilities: [String] + let nodeName: String? + let conversationId: String? + let harnessSessionId: String? + let updatedAt: TimeInterval? + + var displayName: String { name.nilIfEmpty ?? handle?.nilIfEmpty ?? id } + var detail: String { [role, harness, transport].compactMap { $0?.nilIfEmpty }.joined(separator: " · ") } + var workspace: String { projectRoot?.nilIfEmpty ?? cwd?.nilIfEmpty ?? project?.nilIfEmpty ?? "—" } + var branchLabel: String { branch?.nilIfEmpty ?? "—" } + var updatedLabel: String { ScoutRelativeTime.format(updatedAt) } + + enum CodingKeys: String, CodingKey { + case id + case name + case handle + case harness + case state + case role + case projectRoot + case cwd + case project + case branch + case selector + case model + case transport + case capabilities + case authorityNodeName + case homeNodeName + case conversationId + case harnessSessionId + case updatedAt + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(String.self, forKey: .id) + name = try c.decodeIfPresent(String.self, forKey: .name) ?? id + handle = try c.decodeIfPresent(String.self, forKey: .handle) + harness = try c.decodeIfPresent(String.self, forKey: .harness) + state = ScoutAgentState.from(raw: try c.decodeIfPresent(String.self, forKey: .state)) + role = try c.decodeIfPresent(String.self, forKey: .role) + projectRoot = try c.decodeIfPresent(String.self, forKey: .projectRoot) + cwd = try c.decodeIfPresent(String.self, forKey: .cwd) + project = try c.decodeIfPresent(String.self, forKey: .project) + branch = try c.decodeIfPresent(String.self, forKey: .branch) + selector = try c.decodeIfPresent(String.self, forKey: .selector) + model = try c.decodeIfPresent(String.self, forKey: .model) + transport = try c.decodeIfPresent(String.self, forKey: .transport) + capabilities = try c.decodeIfPresent([String].self, forKey: .capabilities) ?? [] + nodeName = try c.decodeIfPresent(String.self, forKey: .authorityNodeName) + ?? c.decodeIfPresent(String.self, forKey: .homeNodeName) + conversationId = try c.decodeIfPresent(String.self, forKey: .conversationId) + harnessSessionId = try c.decodeIfPresent(String.self, forKey: .harnessSessionId) + updatedAt = try c.decodeIfPresent(TimeInterval.self, forKey: .updatedAt) + } +} + +enum ScoutRelativeTime { + static func format(_ raw: TimeInterval?, now: Date = Date()) -> String { + guard let raw else { return "—" } + let seconds = raw > 10_000_000_000 ? raw / 1000 : raw + let delta = max(0, Int(now.timeIntervalSince(Date(timeIntervalSince1970: seconds)))) + if delta < 60 { return "\(delta)s" } + if delta < 3600 { return "\(delta / 60)m" } + if delta < 86_400 { + let h = delta / 3600 + let m = (delta % 3600) / 60 + return m == 0 ? "\(h)h" : "\(h)h \(m)m" + } + return "\(delta / 86_400)d" + } +} + +func uniqueMemberNames(_ names: [String]) -> [String] { + var result: [String] = [] + for name in names { + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + if !result.contains(where: { $0.caseInsensitiveCompare(trimmed) == .orderedSame }) { + result.append(trimmed) + } + } + return result +} + +extension String { + var nilIfEmpty: String? { + trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : self + } +} diff --git a/apps/macos/Sources/Scout/ScoutRootView.swift b/apps/macos/Sources/Scout/ScoutRootView.swift new file mode 100644 index 00000000..938f59d0 --- /dev/null +++ b/apps/macos/Sources/Scout/ScoutRootView.swift @@ -0,0 +1,1708 @@ +import HudsonShell +import HudsonUI +import ScoutNativeCore +import ScoutSharedUI +import SwiftUI +#if os(macOS) +import AppKit +#endif + +struct ScoutRootView: View { + @StateObject private var store = ScoutCommsStore() + @ObservedObject private var vox = ScoutVoxService.shared + @State private var section: ScoutSection = .comms + @State private var railCompact = false + @State private var inspectorCollapsed = false + @State private var channelFilter: ScoutChannelFilter = .all + @State private var draft = "" + @State private var suggestions: [MessageSuggestion] = [] + @State private var selectedSuggestionIndex = 0 + @State private var currentSuggestionTrigger: MessageSuggestionTrigger? + @State private var dismissedSuggestionSignature: String? + @State private var conversationListResizePreviewWidth: CGFloat? + @FocusState private var composerFocused: Bool + @AppStorage("scout.navigationSidebar.labelWidth") private var navigationSidebarLabelWidth = 142.0 + @AppStorage("scout.conversationList.width") private var conversationListWidth = 286.0 + + private var manifest: HudAppManifest { + HudAppManifest( + name: "Scout", + version: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.1.1", + tint: .green, + targetLabel: "Agent" + ) + } + + var body: some View { + HudChromeShell(titlebarStyle: .systemToolbar, titlebarActions: chromeTitlebarActions) { + HudResizableNavigationSidebar( + selection: Binding( + get: { section }, + set: { next in + if let next { + section = next + } + } + ), + entries: sidebarEntries, + isCompact: $railCompact, + labelWidth: navigationSidebarLabelWidthBinding, + accent: manifest.accent, + minLabelWidth: 112, + maxLabelWidth: 260, + collapseLabelWidth: 44, + railHeader: { + Text("S") + .font(HudFont.mono(13, weight: .bold)) + .foregroundStyle(HudPalette.bg) + .frame(width: 24, height: 24) + .background(RoundedRectangle(cornerRadius: 6, style: .continuous).fill(manifest.accent)) + }, + labelHeader: { + Text("Scout") + .font(HudFont.ui(HudTextSize.base, weight: .semibold)) + .foregroundStyle(HudPalette.ink) + .lineLimit(1) + }, + footer: { + ScoutSidebarSettingsButton( + isCompact: railCompact, + labelWidth: CGFloat(navigationSidebarLabelWidth) + ) { + ScoutWeb.open(path: "/settings") + } + } + ) + } trailing: { + HudInspector(isCollapsed: $inspectorCollapsed) { + inspectorHeader + } content: { + inspectorContent + } + } content: { + content + } statusBar: { + statusBar + } + .hudsonAppManifest(manifest) + .environment(\.hudTheme, ScoutDesign.theme) + .environment(\.hudsonSidebarStyle, HudSidebarStyle( + surface: .base, + indicator: .editorial, + icon: .editorial, + motion: .base + )) + .hudsonSidebarMotionMode(.smoothFade) + .onAppear { store.start() } + .onDisappear { store.stop() } + } + + private var sidebarEntries: [HudSidebarEntry] { + [ + .item(HudSidebarItem(id: .comms, title: "Comms", icon: "bubble.left.and.bubble.right", selectedIcon: "bubble.left.and.bubble.right.fill")), + .item(HudSidebarItem(id: .agents, title: "Agents", icon: "person.2", selectedIcon: "person.2.fill")), + ] + } + + private var chromeTitlebarActions: [HudChromeTitlebarAction] { + [ + HudChromeTitlebarAction( + id: "scout.navigation", + placement: .leading, + label: railCompact ? "Expand navigation" : "Collapse navigation", + systemImage: "sidebar.left" + ) { + withAnimation(HudSidebarMotion.expandCollapse) { + railCompact.toggle() + } + }, + HudChromeTitlebarAction( + id: "scout.inspector", + placement: .trailing, + label: inspectorCollapsed ? "Show context" : "Hide context", + systemImage: "sidebar.right" + ) { + withAnimation(.easeOut(duration: 0.14)) { + inspectorCollapsed.toggle() + } + }, + ] + } + + @ViewBuilder + private var content: some View { + switch section { + case .comms: + commsContent + case .agents: + agentsContent + } + } + + private var commsContent: some View { + HStack(spacing: 0) { + ScoutConversationListBar( + isLoading: store.isLoading, + query: $store.channelQuery, + filter: $channelFilter, + channels: commsListChannels, + totalCount: store.channels.count, + selectedCId: store.selectedCId, + width: CGFloat(conversationListWidth) + ) { channel in + store.selectChannel(channel.cId) + } + .overlay(alignment: .trailing) { + ZStack(alignment: .trailing) { + if let conversationListResizePreviewWidth { + Rectangle() + .fill(HudPalette.accent.opacity(0.62)) + .frame(width: HudStrokeWidth.standard) + .offset(x: conversationListResizePreviewWidth - CGFloat(conversationListWidth)) + .allowsHitTesting(false) + } + + ScoutConversationResizeHandle( + width: conversationListWidthBinding, + previewWidth: $conversationListResizePreviewWidth, + range: ScoutDesign.conversationListWidthRange + ) + .frame(width: ScoutDesign.conversationResizeHandleWidth) + .offset(x: ScoutDesign.conversationResizeHandleWidth / 2) + } + } + + chatDetail + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .background(ScoutDesign.bg) + } + + private var chatDetail: some View { + VStack(spacing: 0) { + chatHeader + HudDivider(color: ScoutDesign.hairline) + messageList + HudDivider(color: ScoutDesign.hairline) + composer + } + } + + private var chatHeader: some View { + HStack(spacing: HudSpacing.xl) { + VStack(alignment: .leading, spacing: HudSpacing.sm) { + Text(store.selectedChannel?.displayTitle ?? "Scout") + .font(HudFont.ui(22, weight: .semibold)) + .foregroundStyle(HudPalette.ink) + .lineLimit(1) + + HStack(spacing: HudSpacing.md) { + if let channel = store.selectedChannel { + HudBadge(channel.scope.label, tint: channel.scope == .direct ? HudPalette.statusInfo : HudPalette.statusOk) + HudBadge(channel.cIdShort, tint: HudPalette.muted) + ScoutMemberStrip(names: channel.participantDisplayNames) + } else { + HudBadge("No channel", tint: HudPalette.muted) + } + } + } + + Spacer() + + if let agent = store.selectedAgent { + HudButton("Agent", icon: "person.crop.circle", style: .secondary) { + store.selectAgent(agent.id) + section = .agents + } + } + + HudButton("Open Web", icon: "safari", style: .ghost) { + if let cId = store.selectedCId { + ScoutWeb.open(path: "/c/\(cId)") + } else { + ScoutWeb.open(path: "/messages") + } + } + } + .padding(.horizontal, HudSpacing.huge) + .frame(height: 76) + .background(ScoutDesign.bg) + } + + private var messageList: some View { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: HudSpacing.xl) { + if store.messages.isEmpty { + HudEmptyState( + title: store.selectedChannel == nil ? "No channel selected" : "No messages yet", + subtitle: store.selectedChannel == nil ? "Choose a DM or channel from the list." : "This cId has no visible messages.", + icon: "bubble.left" + ) + .frame(maxWidth: .infinity, minHeight: 360) + } else { + ForEach(store.messages) { message in + ScoutMessageRow(message: message) + .id(message.id) + } + } + } + .padding(HudSpacing.huge) + .frame(maxWidth: .infinity, alignment: .topLeading) + } + .scrollIndicators(.visible) + .onChange(of: store.messages.count) { _, _ in + if let last = store.messages.last { + withAnimation(.easeOut(duration: 0.16)) { + proxy.scrollTo(last.id, anchor: .bottom) + } + } + } + } + } + + private var composer: some View { + VStack(alignment: .leading, spacing: HudSpacing.sm) { + if !suggestions.isEmpty { + MessageSuggestionPopover( + suggestions: suggestions, + selectedIndex: selectedSuggestionIndex, + style: .scout, + onHover: selectSuggestion, + onSelect: { _ = applySuggestion($0) } + ) + .frame(maxWidth: 460) + } + + HStack(alignment: .top, spacing: HudSpacing.lg) { + Button { + toggleDictation() + } label: { + Image(systemName: micSymbol) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(isDictating ? HudPalette.accent : HudPalette.muted) + .frame(width: 30, height: 30) + .background( + RoundedRectangle(cornerRadius: HudRadius.standard, style: .continuous) + .fill(HudSurface.control) + ) + .overlay( + RoundedRectangle(cornerRadius: HudRadius.standard, style: .continuous) + .stroke(isDictating ? HudSurface.tintBorder(HudPalette.accent) : ScoutDesign.hairlineStrong, lineWidth: HudStrokeWidth.thin) + ) + } + .buttonStyle(.plain) + .padding(.top, 1) + .help(isDictating ? "Stop dictation" : "Dictate with Vox") + + if let route = composerRouteLabel { + MessageRouteChip(label: route, style: .scout) + .padding(.top, HudSpacing.md) + } + + if let context = composerContextLabel { + MessageContextPill(name: context, style: .scout) + .padding(.top, HudSpacing.md) + } + + ZStack(alignment: .topLeading) { + TextField(showDictationPreview ? "" : composerPlaceholder, text: $draft, axis: .vertical) + .textFieldStyle(.plain) + .font(HudFont.mono(11)) + .foregroundStyle(HudPalette.ink) + .lineLimit(1...5) + .focused($composerFocused) + .disabled(store.selectedCId == nil || store.isSending) + .onKeyPress(phases: .down) { press in + if press.key == .return { + if applySelectedSuggestion() { return .handled } + if press.modifiers.contains(.shift) { return .ignored } + sendDraft() + return .handled + } + return .ignored + } + .onKeyPress(.upArrow) { + guard !suggestions.isEmpty else { return .ignored } + stepSuggestion(-1) + return .handled + } + .onKeyPress(.downArrow) { + guard !suggestions.isEmpty else { return .ignored } + stepSuggestion(1) + return .handled + } + .onKeyPress(.escape) { + guard !suggestions.isEmpty else { return .ignored } + dismissSuggestions() + return .handled + } + + if showDictationPreview { + ScoutDictationPreview(text: vox.partial.isEmpty ? voxStatusLine : vox.partial) + .allowsHitTesting(false) + } + } + .padding(.horizontal, HudSpacing.xl) + .padding(.vertical, HudSpacing.lg) + .background(RoundedRectangle(cornerRadius: HudRadius.standard).fill(HudSurface.control)) + .overlay( + RoundedRectangle(cornerRadius: HudRadius.standard) + .stroke(composerFocused ? HudSurface.tintBorder(HudPalette.accent) : ScoutDesign.hairlineStrong, lineWidth: HudStrokeWidth.thin) + ) + + MessageSendChip( + isEnabled: composerCanSend, + isSending: store.isSending, + style: .scout, + action: sendDraft + ) + .padding(.top, HudSpacing.md) + } + + if let status = composerStatusText { + Text(status) + .font(HudFont.mono(9)) + .foregroundStyle(HudPalette.dim) + .frame(maxWidth: .infinity, alignment: .trailing) + } + } + .padding(HudSpacing.xxl) + .background(ScoutDesign.chrome) + .onChange(of: draft) { _, _ in refreshSuggestions() } + .onChange(of: store.agents.count) { _, _ in refreshSuggestions() } + .onReceive(vox.$lastFinalText) { spliceDictatedFinal($0) } + } + + private var composerPlaceholder: String { + if let title = store.selectedChannel?.displayTitle, !title.isEmpty { + return "Message \(title)" + } + return "Message" + } + + private var composerCanSend: Bool { + !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && store.selectedCId != nil + && !store.isSending + } + + private var composerRouteLabel: String? { + guard let channel = store.selectedChannel else { return nil } + if channel.scope == .shared { + return "#\(channel.displayTitle)" + } + return channel.agentName?.nilIfEmpty ?? channel.displayTitle + } + + private var composerContextLabel: String? { + guard let channel = store.selectedChannel else { return nil } + return channel.currentBranch?.nilIfEmpty ?? channel.scope.label.lowercased() + } + + private var composerStatusText: String? { + if store.selectedChannel == nil { return "Select a conversation to message" } + if isDictating { return voxStatusLine } + if let reason = voxUnavailableReason { return reason } + if store.isSending { return "Sending..." } + if draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return "Type / for commands · @ for agents · sid: for sessions" + } + return "↵ send · ⇧↵ newline" + } + + private var showDictationPreview: Bool { + draft.isEmpty && (vox.state.isCaptureActive || vox.state.isProcessing) + } + + private var isDictating: Bool { + switch vox.state { + case .starting, .recording, .processing: return true + default: return false + } + } + + private var micSymbol: String { + switch vox.state { + case .recording: return "stop.fill" + case .starting, .processing: return "waveform" + default: return "mic.fill" + } + } + + private var voxStatusLine: String { + if !vox.partial.isEmpty { return vox.partial } + switch vox.state { + case .starting: return "Starting Vox..." + case .processing: return "Transcribing..." + default: return "Listening..." + } + } + + private var voxUnavailableReason: String? { + if case .unavailable(let reason) = vox.state { return reason } + return nil + } + + private func sendDraft() { + let body = draft + guard composerCanSend else { return } + draft = "" + composerFocused = true + clearSuggestions(resetDismissedSignature: true) + Task { await store.send(body) } + } + + private func toggleDictation() { + composerFocused = true + Task { + switch ScoutDictationController.toggleDecision(for: vox.state) { + case .probeThenStartIfIdle: + await vox.probe() + if case .idle = vox.state { vox.start() } + case .start: + vox.start() + case .stop: + vox.stop() + case .ignore: + break + } + } + } + + private func spliceDictatedFinal(_ text: String) { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + draft = ScoutDictationBuffer.appending(trimmed, to: draft) + ScoutVoxService.shared.consumeFinalText() + composerFocused = true + } + + private func refreshSuggestions() { + guard let trigger = MessageSuggestionEngine.detectTrigger(in: draft) else { + clearSuggestions(resetDismissedSignature: true) + return + } + + currentSuggestionTrigger = trigger + if dismissedSuggestionSignature == trigger.signature { + suggestions = [] + selectedSuggestionIndex = 0 + return + } + + let next = MessageSuggestionEngine.suggestions( + for: trigger, + agents: store.agents.map(MessageSuggestionAgent.init) + ) + suggestions = next + selectedSuggestionIndex = next.isEmpty ? 0 : min(selectedSuggestionIndex, next.count - 1) + } + + private func clearSuggestions(resetDismissedSignature: Bool = false) { + suggestions = [] + selectedSuggestionIndex = 0 + currentSuggestionTrigger = nil + if resetDismissedSignature { + dismissedSuggestionSignature = nil + } + } + + private func dismissSuggestions() { + dismissedSuggestionSignature = currentSuggestionTrigger?.signature + suggestions = [] + selectedSuggestionIndex = 0 + } + + private func selectSuggestion(_ index: Int) { + guard !suggestions.isEmpty else { return } + selectedSuggestionIndex = max(0, min(index, suggestions.count - 1)) + } + + private func stepSuggestion(_ delta: Int) { + guard !suggestions.isEmpty else { return } + selectedSuggestionIndex = (selectedSuggestionIndex + delta + suggestions.count) % suggestions.count + } + + @discardableResult + private func applySelectedSuggestion() -> Bool { + guard !suggestions.isEmpty else { return false } + return applySuggestion(suggestions[min(selectedSuggestionIndex, suggestions.count - 1)]) + } + + @discardableResult + private func applySuggestion(_ suggestion: MessageSuggestion) -> Bool { + guard let trigger = currentSuggestionTrigger, + let start = MessageSuggestionEngine.index(in: draft, offset: trigger.startOffset), + let end = MessageSuggestionEngine.index(in: draft, offset: trigger.endOffset) else { + return false + } + + let before = String(draft[.. { + Binding { + CGFloat(conversationListWidth) + } set: { nextWidth in + let range = ScoutDesign.conversationListWidthRange + conversationListWidth = Double(min(max(nextWidth, range.lowerBound), range.upperBound)) + } + } + + private var navigationSidebarLabelWidthBinding: Binding { + Binding { + CGFloat(navigationSidebarLabelWidth) + } set: { nextWidth in + navigationSidebarLabelWidth = Double(min(max(nextWidth, 112), 260)) + } + } + + private var pickerChannels: [ScoutChannel] { + if section == .agents, let agent = store.selectedAgent { + return filterChannels(store.channels.filter { channel in + channel.agentId == agent.id + || channel.participantIds.contains(agent.id) + || channel.cId.localizedCaseInsensitiveContains(agent.id) + || channel.participantDisplayNames.contains(where: { $0.localizedCaseInsensitiveContains(agent.displayName) }) + }) + } + return store.visibleChannels + } + + private func filterChannels(_ channels: [ScoutChannel]) -> [ScoutChannel] { + let trimmed = store.channelQuery.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return channels } + return channels.filter { channel in + channel.displayTitle.localizedCaseInsensitiveContains(trimmed) + || channel.cId.localizedCaseInsensitiveContains(trimmed) + || channel.participantDisplayNames.joined(separator: " ").localizedCaseInsensitiveContains(trimmed) + } + } + + private var statusBar: some View { + HStack(spacing: HudSpacing.xl) { + HudStatusDot(color: store.lastError == nil ? HudPalette.statusOk : HudPalette.statusError) + Text("SCOUT") + .font(HudFont.mono(10, weight: .bold)) + .tracking(1.4) + .foregroundStyle(HudPalette.muted) + + Text("·") + .font(HudFont.mono(10)) + .foregroundStyle(HudPalette.dim) + + Text("\(store.channels.count) cIds") + .font(HudFont.mono(10)) + .foregroundStyle(HudPalette.muted) + + Text("·") + .font(HudFont.mono(10)) + .foregroundStyle(HudPalette.dim) + + Text("\(store.agents.count) agents") + .font(HudFont.mono(10)) + .foregroundStyle(HudPalette.muted) + + if let error = store.lastError { + Text("·") + .font(HudFont.mono(10)) + .foregroundStyle(HudPalette.dim) + Text(error) + .font(HudFont.mono(10)) + .foregroundStyle(HudPalette.statusError) + .lineLimit(1) + } + + Spacer() + } + .padding(.horizontal, HudSpacing.xxl) + .frame(height: 24) + .background(ScoutDesign.chrome) + } +} + +private enum ScoutDesign { + static let bg = Color(red: 8.0/255, green: 8.0/255, blue: 7.0/255) + static let chrome = Color(red: 6.0/255, green: 6.0/255, blue: 5.0/255) + static let surface = Color(red: 18.0/255, green: 17.0/255, blue: 15.0/255) + static let hairline = Color.white.opacity(0.045) + static let hairlineStrong = Color.white.opacity(0.075) + static let conversationListWidthRange: ClosedRange = 230...430 + static let conversationResizeHandleWidth: CGFloat = 12 + + static let theme = HudTheme( + palette: HudThemePalette( + bg: bg, + surface: surface, + chrome: chrome, + ink: HudPalette.ink, + muted: HudPalette.muted, + dim: HudPalette.dim, + border: hairlineStrong, + accent: HudPalette.accent, + accentSoft: HudPalette.accentSoft, + statusOk: HudPalette.statusOk, + statusWarn: HudPalette.statusWarn, + statusError: HudPalette.statusError, + statusInfo: HudPalette.statusInfo + ), + hairline: HudThemeHairline( + subtle: hairline, + standard: hairlineStrong + ), + radius: .default, + focus: .default + ) +} + +private enum ScoutChannelFilter: String, CaseIterable, Identifiable { + case all + case direct + case shared + + var id: String { rawValue } + + var title: String { + switch self { + case .all: return "All" + case .direct: return "Private" + case .shared: return "Shared" + } + } + + var icon: String { + switch self { + case .all: return "tray.full" + case .direct: return "person.crop.circle" + case .shared: return "number" + } + } + + func apply(to channels: [ScoutChannel]) -> [ScoutChannel] { + switch self { + case .all: + return channels + case .direct: + return channels.filter { $0.scope == .direct } + case .shared: + return channels.filter { $0.scope == .shared } + } + } +} + +private struct ScoutConversationListBar: View { + let isLoading: Bool + @Binding var query: String + @Binding var filter: ScoutChannelFilter + let channels: [ScoutChannel] + let totalCount: Int + let selectedCId: String? + let width: CGFloat + let select: (ScoutChannel) -> Void + + var body: some View { + VStack(spacing: 0) { + header + controls + HudDivider(color: ScoutDesign.hairline) + listContent + } + .frame(width: width) + .frame(maxHeight: .infinity) + .background(ScoutDesign.chrome) + } + + private var header: some View { + HStack(spacing: HudSpacing.md) { + VStack(alignment: .leading, spacing: 2) { + Text("Conversations") + .font(HudFont.ui(14, weight: .semibold)) + .foregroundStyle(HudPalette.ink) + .lineLimit(1) + Text("\(totalCount) cIds") + .font(HudFont.mono(9)) + .foregroundStyle(HudPalette.dim) + .lineLimit(1) + } + + Spacer() + + if isLoading { + ProgressView() + .controlSize(.small) + } else { + HudBadge("\(channels.count)", tint: HudPalette.muted) + } + } + .padding(.horizontal, HudSpacing.xxl) + .frame(height: 58) + } + + private var controls: some View { + VStack(spacing: HudSpacing.lg) { + HudField("Search", text: $query, icon: "magnifyingglass") + ScoutConversationFilterControl(selection: $filter) + } + .padding(.horizontal, HudSpacing.xxl) + .padding(.bottom, HudSpacing.xxl) + } + + @ViewBuilder + private var listContent: some View { + if isLoading && channels.isEmpty { + VStack(spacing: HudSpacing.md) { + ProgressView() + Text("Loading channels") + .font(HudFont.mono(10)) + .foregroundStyle(HudPalette.dim) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if channels.isEmpty { + HudEmptyState( + title: query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "No conversations" : "No matches", + subtitle: query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "No visible DMs or channels." : "Try another search or filter.", + icon: "bubble.left" + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(HudSpacing.xxl) + } else { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(channels) { channel in + ScoutConversationRow( + channel: channel, + isSelected: selectedCId == channel.cId + ) { + select(channel) + } + } + } + .padding(.vertical, HudSpacing.sm) + } + .scrollIndicators(.visible) + } + } +} + +private struct ScoutConversationFilterControl: View { + @Binding var selection: ScoutChannelFilter + + var body: some View { + HStack(spacing: HudSpacing.xs) { + ForEach(ScoutChannelFilter.allCases) { option in + Button { + selection = option + } label: { + HStack(spacing: HudSpacing.xs) { + Image(systemName: option.icon) + .font(HudFont.ui(10, weight: .semibold)) + Text(option.title) + .font(HudFont.mono(9, weight: .semibold)) + } + .foregroundStyle(selection == option ? HudPalette.ink : HudPalette.muted) + .frame(maxWidth: .infinity) + .frame(height: 26) + .background( + RoundedRectangle(cornerRadius: HudRadius.standard, style: .continuous) + .fill(selection == option ? HudSurface.selected(HudPalette.accent) : Color.clear) + ) + .overlay( + RoundedRectangle(cornerRadius: HudRadius.standard, style: .continuous) + .stroke(selection == option ? HudSurface.tintBorder(HudPalette.accent) : Color.clear, lineWidth: HudStrokeWidth.thin) + ) + } + .buttonStyle(.plain) + } + } + .padding(3) + .background(RoundedRectangle(cornerRadius: HudRadius.standard, style: .continuous).fill(HudSurface.inset)) + .overlay(RoundedRectangle(cornerRadius: HudRadius.standard, style: .continuous).stroke(ScoutDesign.hairline, lineWidth: HudStrokeWidth.thin)) + } +} + +private struct ScoutConversationRow: View { + let channel: ScoutChannel + let isSelected: Bool + let action: () -> Void + + @State private var isHovering = false + + var body: some View { + Button(action: action) { + HStack(alignment: .top, spacing: HudSpacing.md) { + Image(systemName: channel.scope == .direct ? "person.crop.circle" : "number") + .font(HudFont.ui(13, weight: .semibold)) + .foregroundStyle(isSelected ? HudPalette.accent : HudPalette.muted) + .frame(width: 20, height: 20) + .padding(.top, 1) + + VStack(alignment: .leading, spacing: HudSpacing.xs) { + HStack(alignment: .firstTextBaseline, spacing: HudSpacing.sm) { + Text(channel.displayTitle) + .font(HudFont.ui(13, weight: isSelected ? .semibold : .medium)) + .foregroundStyle(HudPalette.ink) + .lineLimit(1) + + Spacer(minLength: HudSpacing.sm) + + Text(channel.ageLabel) + .font(HudFont.mono(8)) + .foregroundStyle(HudPalette.dim) + .lineLimit(1) + } + + Text(channel.preview?.nilIfEmpty ?? channel.participantDisplayNames.joined(separator: " + ")) + .font(HudFont.ui(11)) + .foregroundStyle(HudPalette.muted) + .lineLimit(2) + + HStack(spacing: HudSpacing.sm) { + Text(channel.scope.label.uppercased()) + .font(HudFont.mono(8, weight: .semibold)) + .foregroundStyle(channel.scope == .direct ? HudPalette.statusInfo : HudPalette.statusOk) + Text(channel.cIdShort) + .font(HudFont.mono(8)) + .foregroundStyle(HudPalette.dim) + .lineLimit(1) + Spacer(minLength: 0) + if channel.messageCount > 0 { + Text("\(channel.messageCount)") + .font(HudFont.mono(8, weight: .semibold)) + .foregroundStyle(HudPalette.dim) + } + } + } + } + .padding(.horizontal, HudSpacing.xxl) + .padding(.vertical, HudSpacing.lg) + .frame(maxWidth: .infinity, alignment: .leading) + .background(rowBackground) + .overlay(alignment: .leading) { + Rectangle() + .fill(isSelected ? HudPalette.accent : Color.clear) + .frame(width: 2) + } + .overlay(alignment: .bottom) { + HudDivider(color: ScoutDesign.hairline) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onHover { isHovering = $0 } + .animation(.easeOut(duration: 0.10), value: isHovering) + .animation(.easeOut(duration: 0.10), value: isSelected) + } + + private var rowBackground: Color { + if isSelected { + return HudSurface.selected(HudPalette.accent) + } + if isHovering { + return HudSurface.hover + } + return Color.clear + } +} + +private struct ScoutSidebarSettingsButton: View { + let isCompact: Bool + let labelWidth: CGFloat + let action: () -> Void + + @State private var isHovering = false + + var body: some View { + Button(action: action) { + HStack(spacing: 0) { + Image(systemName: isHovering ? "gearshape.fill" : "gearshape") + .font(HudFont.ui(13, weight: .semibold)) + .foregroundStyle(isHovering ? HudPalette.ink : HudPalette.muted) + .frame(width: HudSidebarLayout.railWidth, height: 32) + + Text("Settings") + .font(HudFont.ui(HudTextSize.sm, weight: .medium)) + .foregroundStyle(isHovering ? HudPalette.ink : HudPalette.muted) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + .padding(.leading, HudSidebarLayout.labelLeading) + .frame(width: labelWidth, alignment: .leading) + .opacity(isCompact ? 0 : 1) + } + .frame(width: HudSidebarLayout.railWidth + (isCompact ? 0 : labelWidth), height: 32, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: HudRadius.standard, style: .continuous) + .fill(isHovering ? HudSurface.hover : Color.clear) + ) + .contentShape(Rectangle()) + .clipped() + } + .buttonStyle(.plain) + .help("Settings") + .accessibilityLabel("Settings") + .onHover { isHovering = $0 } + .animation(.easeOut(duration: 0.10), value: isHovering) + .animation(.easeOut(duration: 0.12), value: isCompact) + } +} + +#if os(macOS) +private struct ScoutConversationResizeHandle: NSViewRepresentable { + @Binding var width: CGFloat + @Binding var previewWidth: CGFloat? + let range: ClosedRange + + func makeNSView(context: Context) -> ResizeHandleView { + let view = ResizeHandleView() + view.range = range + view.getWidth = { width } + view.setPreviewWidth = { previewWidth = $0 } + view.commitWidth = { width = $0 } + view.clearPreview = { previewWidth = nil } + return view + } + + func updateNSView(_ view: ResizeHandleView, context: Context) { + view.range = range + view.getWidth = { width } + view.setPreviewWidth = { previewWidth = $0 } + view.commitWidth = { width = $0 } + view.clearPreview = { previewWidth = nil } + } + + final class ResizeHandleView: NSView { + var range: ClosedRange = 230...430 + var getWidth: () -> CGFloat = { 286 } + var setPreviewWidth: (CGFloat) -> Void = { _ in } + var commitWidth: (CGFloat) -> Void = { _ in } + var clearPreview: () -> Void = {} + + private var startX: CGFloat = 0 + private var startWidth: CGFloat = 0 + private var isActive = false + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + layer?.backgroundColor = NSColor.clear.cgColor + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + wantsLayer = true + layer?.backgroundColor = NSColor.clear.cgColor + } + + override var acceptsFirstResponder: Bool { true } + override var mouseDownCanMoveWindow: Bool { false } + override var intrinsicContentSize: NSSize { + NSSize(width: ScoutDesign.conversationResizeHandleWidth, height: NSView.noIntrinsicMetric) + } + + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { + true + } + + override func resetCursorRects() { + addCursorRect(bounds, cursor: .resizeLeftRight) + } + + override func mouseDown(with event: NSEvent) { + window?.makeFirstResponder(self) + startX = event.locationInWindow.x + startWidth = getWidth() + isActive = true + setPreviewWidth(startWidth) + needsDisplay = true + } + + override func mouseDragged(with event: NSEvent) { + let delta = event.locationInWindow.x - startX + setPreviewWidth(clamp(startWidth + delta)) + } + + override func mouseUp(with event: NSEvent) { + let delta = event.locationInWindow.x - startX + commitWidth(clamp(startWidth + delta)) + clearPreview() + isActive = false + needsDisplay = true + } + + override func draw(_ dirtyRect: NSRect) { + super.draw(dirtyRect) + let color = isActive + ? NSColor.white.withAlphaComponent(0.04) + : NSColor.white.withAlphaComponent(0.06) + color.setFill() + let rect = NSRect(x: floor((bounds.width - 1) / 2), y: 0, width: 1, height: bounds.height) + rect.fill() + } + + private func clamp(_ value: CGFloat) -> CGFloat { + min(max(value, range.lowerBound), range.upperBound) + } + } +} +#else +private struct ScoutConversationResizeHandle: View { + @Binding var width: CGFloat + @Binding var previewWidth: CGFloat? + let range: ClosedRange + + var body: some View { + HudResizableDivider(width: $width, placement: .trailing, range: range, hitWidth: 10) + } +} +#endif + +private struct ScoutChannelPicker: View { + let title: String + let isLoading: Bool + @Binding var query: String + let channels: [ScoutChannel] + let selectedCId: String? + let select: (ScoutChannel) -> Void + + var body: some View { + HudCard(padding: HudSpacing.xl) { + VStack(alignment: .leading, spacing: HudSpacing.lg) { + HStack { + HudSectionLabel(title) + Spacer() + HudBadge("\(channels.count)", tint: HudPalette.muted) + } + + HudField("Find channel", text: $query, icon: "magnifyingglass") + + if isLoading && channels.isEmpty { + VStack(spacing: HudSpacing.md) { + ProgressView() + Text("Loading") + .font(HudFont.mono(9)) + .foregroundStyle(HudPalette.dim) + } + .frame(maxWidth: .infinity) + .padding(.vertical, HudSpacing.xl) + } else if channels.isEmpty { + HudEmptyState(title: "No channels", subtitle: "No matching DM or channel.", icon: "bubble.left") + } else { + ScrollView { + LazyVStack(spacing: HudSpacing.sm) { + ForEach(channels) { channel in + ScoutCompactChannelRow( + channel: channel, + isSelected: selectedCId == channel.cId + ) { + select(channel) + } + } + } + } + .frame(maxHeight: 280) + .scrollIndicators(.visible) + } + } + } + } +} + +private struct ScoutCompactChannelRow: View { + let channel: ScoutChannel + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: HudSpacing.md) { + Image(systemName: channel.scope == .direct ? "person.crop.circle" : "number") + .font(HudFont.ui(12, weight: .semibold)) + .foregroundStyle(isSelected ? HudPalette.accent : HudPalette.muted) + .frame(width: 20) + + VStack(alignment: .leading, spacing: 2) { + Text(channel.displayTitle) + .font(HudFont.ui(12, weight: isSelected ? .semibold : .medium)) + .foregroundStyle(HudPalette.ink) + .lineLimit(1) + Text(channel.preview?.nilIfEmpty ?? channel.cIdShort) + .font(HudFont.mono(9)) + .foregroundStyle(HudPalette.dim) + .lineLimit(1) + } + + Spacer(minLength: 0) + + if channel.messageCount > 0 { + Text("\(channel.messageCount)") + .font(HudFont.mono(9, weight: .semibold)) + .foregroundStyle(HudPalette.dim) + } + } + .padding(.horizontal, HudSpacing.md) + .padding(.vertical, HudSpacing.md) + .background(RoundedRectangle(cornerRadius: HudRadius.standard).fill(isSelected ? HudSurface.selected(HudPalette.accent) : HudSurface.inset)) + .overlay(RoundedRectangle(cornerRadius: HudRadius.standard).stroke(isSelected ? HudSurface.tintBorder(HudPalette.accent) : HudHairline.subtle, lineWidth: 1)) + } + .buttonStyle(.plain) + } +} + +private struct ScoutMemberStrip: View { + let names: [String] + + var body: some View { + HStack(spacing: HudSpacing.sm) { + HStack(spacing: -4) { + ForEach(Array(names.prefix(4).enumerated()), id: \.offset) { index, name in + Text(name.first.map { String($0).uppercased() } ?? "?") + .font(HudFont.mono(8, weight: .bold)) + .foregroundStyle(HudPalette.bg) + .frame(width: 18, height: 18) + .background(Circle().fill(memberTint(name))) + .overlay(Circle().stroke(HudPalette.bg, lineWidth: 1.2)) + .zIndex(Double(8 - index)) + } + } + Text(names.joined(separator: " + ")) + .font(HudFont.ui(11, weight: .medium)) + .foregroundStyle(HudPalette.muted) + .lineLimit(1) + } + } + + private func memberTint(_ name: String) -> Color { + if name.lowercased() == "operator" { return HudPalette.accent } + return Color(hue: Double(stableHueSeed(for: name)) / 360.0, saturation: 0.55, brightness: 0.82) + } + + private func stableHueSeed(for text: String) -> Int { + var hash: UInt64 = 5381 + for byte in text.lowercased().utf8 { + hash = (hash &* 33) &+ UInt64(byte) + } + return Int(hash % 360) + } +} + +private struct ScoutMessageRow: View { + let message: ScoutMessage + + var body: some View { + HStack(alignment: .top) { + if message.isOperator { Spacer(minLength: 80) } + VStack(alignment: .leading, spacing: HudSpacing.sm) { + HStack(spacing: HudSpacing.md) { + Text(message.actorName.uppercased()) + .font(HudFont.mono(9, weight: .bold)) + .foregroundStyle(message.isOperator ? HudPalette.accent : HudPalette.muted) + Text(ScoutRelativeTime.format(message.createdAt)) + .font(HudFont.mono(9)) + .foregroundStyle(HudPalette.dim) + } + ScoutMarkdownView(text: message.body) + } + .padding(HudSpacing.xxl) + .frame(maxWidth: 840, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: HudRadius.card) + .fill(message.isOperator ? HudSurface.tintGhost(HudPalette.accent) : HudPalette.surface) + ) + .overlay( + RoundedRectangle(cornerRadius: HudRadius.card) + .stroke(message.isOperator ? HudSurface.tintBorder(HudPalette.accent) : HudHairline.standard, lineWidth: 1) + ) + if !message.isOperator { Spacer(minLength: 80) } + } + .frame(maxWidth: .infinity, alignment: message.isOperator ? .trailing : .leading) + } +} + +private struct ScoutMarkdownView: View { + let text: String + + var body: some View { + VStack(alignment: .leading, spacing: HudSpacing.md) { + ForEach(MessageMarkupParser.parse(text)) { block in + blockView(block) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + } + + @ViewBuilder + private func blockView(_ block: MessageMarkupBlock) -> some View { + switch block.kind { + case .heading(let level): + Text(inline(block.text)) + .font(HudFont.ui(level == 1 ? 16 : 14, weight: .semibold)) + .foregroundStyle(HudPalette.ink) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, level == 1 ? HudSpacing.xs : 0) + + case .paragraph: + Text(inline(block.text)) + .font(HudFont.ui(13)) + .foregroundStyle(HudPalette.ink) + .lineSpacing(2) + .fixedSize(horizontal: false, vertical: true) + + case .blockquote: + Text(inline(block.text)) + .font(HudFont.ui(12)) + .foregroundStyle(HudPalette.muted) + .lineSpacing(2) + .fixedSize(horizontal: false, vertical: true) + .padding(.leading, HudSpacing.xl) + .overlay(alignment: .leading) { + Rectangle() + .fill(HudSurface.tintBorder(HudPalette.accent)) + .frame(width: 2) + } + + case .list(let ordered, let items): + VStack(alignment: .leading, spacing: HudSpacing.sm) { + ForEach(Array(items.enumerated()), id: \.offset) { index, item in + HStack(alignment: .top, spacing: HudSpacing.md) { + Text(ordered ? "\(index + 1)." : "-") + .font(HudFont.mono(12, weight: .semibold)) + .foregroundStyle(HudPalette.accent) + .frame(width: ordered ? 24 : 10, alignment: .trailing) + Text(inline(item)) + .font(HudFont.ui(13)) + .foregroundStyle(HudPalette.ink) + .fixedSize(horizontal: false, vertical: true) + } + } + } + + case .code(let language): + MessageCodeBlock(language: language, text: block.text, style: .scout) + + case .table(let headers, let rows): + ScoutMarkdownTable(headers: headers, rows: rows) + + case .rule: + HudDivider(color: ScoutDesign.hairlineStrong) + .padding(.vertical, HudSpacing.xs) + } + } + + private func inline(_ body: String) -> AttributedString { + (try? AttributedString( + markdown: body, + options: AttributedString.MarkdownParsingOptions(interpretedSyntax: .inlineOnlyPreservingWhitespace) + )) ?? AttributedString(body) + } +} + +private extension MessageCodeBlockStyle { + static let scout = MessageCodeBlockStyle( + labelFont: HudFont.mono(8, weight: .bold), + codeFont: HudFont.mono(11), + labelColor: HudPalette.dim, + codeColor: HudPalette.ink, + backgroundColor: HudSurface.inset, + borderColor: ScoutDesign.hairlineStrong, + cornerRadius: HudRadius.standard, + borderWidth: HudStrokeWidth.thin, + contentInsets: EdgeInsets(top: HudSpacing.md, leading: HudSpacing.xl, bottom: HudSpacing.xl, trailing: HudSpacing.xl), + blockSpacing: HudSpacing.md, + labelTracking: 0, + showsScrollIndicators: false + ) +} + +private extension MessageRouteChipStyle { + static let scout = MessageRouteChipStyle( + font: HudFont.mono(10, weight: .semibold), + textColor: HudPalette.accent, + borderColor: HudSurface.tintBorder(HudPalette.accent), + horizontalPadding: HudSpacing.md, + verticalPadding: HudSpacing.xs, + cornerRadius: HudRadius.tight + ) +} + +private extension MessageContextPillStyle { + static let scout = MessageContextPillStyle( + separatorFont: HudFont.mono(10, weight: .semibold), + textFont: HudFont.mono(10), + separatorColor: HudPalette.dim, + textColor: HudPalette.muted + ) +} + +private extension MessageSendChipStyle { + static let scout = MessageSendChipStyle( + keyFont: HudFont.mono(10, weight: .semibold), + titleFont: HudFont.mono(10, weight: .semibold), + tracking: 1.4, + enabledColor: HudPalette.accent, + hoverColor: HudPalette.ink, + disabledColor: HudPalette.dim, + horizontalPadding: HudSpacing.xs, + verticalPadding: HudSpacing.xs + ) +} + +private extension MessageSuggestionPopoverStyle { + static let scout = MessageSuggestionPopoverStyle( + eyebrowFont: HudFont.mono(10, weight: .bold), + markFont: HudFont.mono(9, weight: .bold), + labelFont: HudFont.mono(11, weight: .semibold), + detailFont: HudFont.ui(10), + eyebrowColor: HudPalette.dim, + commandAccent: HudPalette.accent, + agentAccent: HudPalette.ink, + sessionAccent: HudPalette.statusInfo, + selectedLabelColor: HudPalette.ink, + labelColor: HudPalette.muted, + detailColor: HudPalette.dim, + selectedBackgroundColor: HudSurface.selected(HudPalette.accent), + backgroundColor: ScoutDesign.surface, + borderColor: ScoutDesign.hairlineStrong, + shadowColor: Color.black.opacity(0.24), + cornerRadius: HudRadius.standard, + borderWidth: HudStrokeWidth.thin + ) +} + +private extension MessageSuggestionAgent { + init(_ agent: ScoutAgent) { + self.init( + id: agent.id, + name: agent.displayName, + handle: agent.handle, + state: agent.state.rawValue, + role: agent.role, + workspaceRoot: agent.workspace, + harnessSessionId: agent.harnessSessionId + ) + } +} + +private struct ScoutDictationPreview: View { + let text: String + @State private var caretLit = false + + private var displayText: String { + text.trimmingCharacters(in: .whitespacesAndNewlines) + } + + var body: some View { + HStack(spacing: HudSpacing.xs) { + if !displayText.isEmpty { + Text(displayText) + .font(HudFont.mono(11)) + .foregroundStyle(HudPalette.muted) + .lineLimit(1) + .truncationMode(.tail) + } + RoundedRectangle(cornerRadius: 0.5, style: .continuous) + .fill(HudPalette.accent.opacity(caretLit ? 0.95 : 0.25)) + .frame(width: 1, height: 13) + } + .frame(maxWidth: .infinity, alignment: .leading) + .onAppear { + withAnimation(.easeInOut(duration: 0.48).repeatForever(autoreverses: true)) { + caretLit = true + } + } + } +} + +private struct ScoutMarkdownTable: View { + let headers: [String] + let rows: [[String]] + + var body: some View { + ScrollView(.horizontal) { + VStack(alignment: .leading, spacing: 0) { + tableRow(headers, isHeader: true) + HudDivider(color: ScoutDesign.hairlineStrong) + ForEach(Array(rows.enumerated()), id: \.offset) { _, row in + tableRow(row, isHeader: false) + } + } + .background(HudSurface.inset) + .clipShape(RoundedRectangle(cornerRadius: HudRadius.standard, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: HudRadius.standard, style: .continuous) + .stroke(ScoutDesign.hairlineStrong, lineWidth: HudStrokeWidth.thin) + ) + } + .scrollIndicators(.hidden) + } + + private func tableRow(_ cells: [String], isHeader: Bool) -> some View { + HStack(spacing: 0) { + ForEach(0.. AttributedString { + (try? AttributedString( + markdown: body, + options: AttributedString.MarkdownParsingOptions(interpretedSyntax: .inlineOnlyPreservingWhitespace) + )) ?? AttributedString(body) + } +} + +private struct ScoutAgentCard: View { + let agent: ScoutAgent + let isSelected: Bool + let select: () -> Void + let openChannel: () -> Void + + var body: some View { + HudCard { + VStack(alignment: .leading, spacing: HudSpacing.lg) { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: HudSpacing.xs) { + Text(agent.displayName) + .font(HudFont.ui(17, weight: .semibold)) + .foregroundStyle(HudPalette.ink) + .lineLimit(1) + Text(agent.id) + .font(HudFont.mono(10)) + .foregroundStyle(HudPalette.dim) + .lineLimit(1) + .truncationMode(.middle) + } + Spacer() + HudBadge(agent.state.label, tint: agent.state.tint, dot: true) + } + + if !agent.detail.isEmpty { + Text(agent.detail) + .font(HudFont.ui(12)) + .foregroundStyle(HudPalette.muted) + .lineLimit(2) + } + + HudInset { + VStack(alignment: .leading, spacing: HudSpacing.md) { + HudKVRow("Branch", value: agent.branchLabel) + HudKVRow("Workspace", value: agent.workspace) + HudKVRow("Updated", value: agent.updatedLabel) + } + } + + HStack { + HudButton("Inspect", icon: "sidebar.right", style: isSelected ? .primary(.green) : .secondary, action: select) + HudButton("Open DM", icon: "bubble.left", style: .ghost, action: openChannel) + } + } + } + } +} + +private struct ScoutAgentInspector: View { + let agent: ScoutAgent + let selectedChannel: ScoutChannel? + let openObserve: () -> Void + let openProfile: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: HudSpacing.xl) { + HudCard { + VStack(alignment: .leading, spacing: HudSpacing.md) { + Text(agent.displayName) + .font(HudFont.ui(18, weight: .semibold)) + .foregroundStyle(HudPalette.ink) + Text(agent.id) + .font(HudFont.mono(10)) + .foregroundStyle(HudPalette.dim) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + HudBadge(agent.state.label, tint: agent.state.tint, dot: true) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + HudCard { + VStack(alignment: .leading, spacing: HudSpacing.md) { + HudSectionLabel("Runtime") + HudKVRow("Harness", value: agent.harness?.nilIfEmpty ?? "—") + HudKVRow("Transport", value: agent.transport?.nilIfEmpty ?? "—") + HudKVRow("Model", value: agent.model?.nilIfEmpty ?? "—") + HudKVRow("Node", value: agent.nodeName?.nilIfEmpty ?? "—") + } + } + + HudCard { + VStack(alignment: .leading, spacing: HudSpacing.md) { + HudSectionLabel("Workspace") + HudKVRow("Branch", value: agent.branchLabel) + HudKVRow("Path", value: agent.workspace) + if let selectedChannel { + HudKVRow("cId", value: selectedChannel.cIdShort) + } + } + } + + if !agent.capabilities.isEmpty { + HudCard { + VStack(alignment: .leading, spacing: HudSpacing.md) { + HudSectionLabel("Capabilities") + FlowLayout(spacing: HudSpacing.sm) { + ForEach(agent.capabilities, id: \.self) { capability in + HudBadge(capability, tint: HudPalette.muted) + } + } + } + } + } + + HStack { + HudButton("Observe", icon: "eye", style: .primary(.green), action: openObserve) + HudButton("Profile", icon: "person.text.rectangle", style: .secondary, action: openProfile) + } + } + } +} + +private struct ScoutChannelInspector: View { + let channel: ScoutChannel + + var body: some View { + VStack(alignment: .leading, spacing: HudSpacing.xl) { + HudCard { + VStack(alignment: .leading, spacing: HudSpacing.md) { + HudSectionLabel("Channel") + Text(channel.displayTitle) + .font(HudFont.ui(18, weight: .semibold)) + .foregroundStyle(HudPalette.ink) + HudBadge(channel.scope.label, tint: channel.scope == .direct ? HudPalette.statusInfo : HudPalette.statusOk) + } + } + + HudCard { + VStack(alignment: .leading, spacing: HudSpacing.md) { + HudKVRow("cId", value: channel.cId) + HudKVRow("Messages", value: "\(channel.messageCount)") + HudKVRow("Branch", value: channel.currentBranch?.nilIfEmpty ?? "—") + } + } + + HudCard { + VStack(alignment: .leading, spacing: HudSpacing.md) { + HudSectionLabel("Members") + ForEach(channel.participantDisplayNames, id: \.self) { name in + HudListRow(title: name, icon: name == "Operator" ? "person" : "cpu", iconTint: name == "Operator" ? .green : .blue) + } + } + } + } + } +} + +private struct FlowLayout: View { + let spacing: CGFloat + let content: Content + + init(spacing: CGFloat, @ViewBuilder content: () -> Content) { + self.spacing = spacing + self.content = content() + } + + var body: some View { + VStack(alignment: .leading, spacing: spacing) { + content + } + } +} diff --git a/apps/macos/Sources/ScoutSharedUI/MessageCodeBlock.swift b/apps/macos/Sources/ScoutSharedUI/MessageCodeBlock.swift new file mode 100644 index 00000000..d659a6fb --- /dev/null +++ b/apps/macos/Sources/ScoutSharedUI/MessageCodeBlock.swift @@ -0,0 +1,91 @@ +import SwiftUI + +public struct MessageCodeBlockStyle: @unchecked Sendable { + let labelFont: Font + let codeFont: Font + let labelColor: Color + let codeColor: Color + let backgroundColor: Color + let borderColor: Color + let cornerRadius: CGFloat + let borderWidth: CGFloat + let contentInsets: EdgeInsets + let blockSpacing: CGFloat + let labelTracking: CGFloat + let showsScrollIndicators: Bool + + public init( + labelFont: Font, + codeFont: Font, + labelColor: Color, + codeColor: Color, + backgroundColor: Color, + borderColor: Color, + cornerRadius: CGFloat = 6, + borderWidth: CGFloat = 1, + contentInsets: EdgeInsets = EdgeInsets(top: 9, leading: 10, bottom: 9, trailing: 10), + blockSpacing: CGFloat = 7, + labelTracking: CGFloat = 1.0, + showsScrollIndicators: Bool = false + ) { + self.labelFont = labelFont + self.codeFont = codeFont + self.labelColor = labelColor + self.codeColor = codeColor + self.backgroundColor = backgroundColor + self.borderColor = borderColor + self.cornerRadius = cornerRadius + self.borderWidth = borderWidth + self.contentInsets = contentInsets + self.blockSpacing = blockSpacing + self.labelTracking = labelTracking + self.showsScrollIndicators = showsScrollIndicators + } +} + +public struct MessageCodeBlock: View { + private let language: String? + private let text: String + private let style: MessageCodeBlockStyle + + public init( + language: String?, + text: String, + style: MessageCodeBlockStyle + ) { + let trimmedLanguage = language?.trimmingCharacters(in: .whitespacesAndNewlines) + self.language = trimmedLanguage?.isEmpty == false ? trimmedLanguage : nil + self.text = text + self.style = style + } + + public var body: some View { + VStack(alignment: .leading, spacing: style.blockSpacing) { + if let language { + Text(language.uppercased()) + .font(style.labelFont) + .tracking(style.labelTracking) + .foregroundStyle(style.labelColor) + } + + ScrollView(.horizontal) { + Text(text) + .font(style.codeFont) + .foregroundStyle(style.codeColor) + .textSelection(.enabled) + .fixedSize(horizontal: true, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } + .scrollIndicators(style.showsScrollIndicators ? .visible : .hidden) + } + .padding(style.contentInsets) + .background( + RoundedRectangle(cornerRadius: style.cornerRadius, style: .continuous) + .fill(style.backgroundColor) + ) + .overlay( + RoundedRectangle(cornerRadius: style.cornerRadius, style: .continuous) + .stroke(style.borderColor, lineWidth: style.borderWidth) + ) + } +} diff --git a/apps/macos/Sources/ScoutSharedUI/MessageInputAtoms.swift b/apps/macos/Sources/ScoutSharedUI/MessageInputAtoms.swift new file mode 100644 index 00000000..76289cb6 --- /dev/null +++ b/apps/macos/Sources/ScoutSharedUI/MessageInputAtoms.swift @@ -0,0 +1,198 @@ +import SwiftUI + +public struct MessageRouteChipStyle: @unchecked Sendable { + let font: Font + let textColor: Color + let borderColor: Color + let horizontalPadding: CGFloat + let verticalPadding: CGFloat + let cornerRadius: CGFloat + + public init( + font: Font, + textColor: Color, + borderColor: Color, + horizontalPadding: CGFloat = 6, + verticalPadding: CGFloat = 2, + cornerRadius: CGFloat = 3 + ) { + self.font = font + self.textColor = textColor + self.borderColor = borderColor + self.horizontalPadding = horizontalPadding + self.verticalPadding = verticalPadding + self.cornerRadius = cornerRadius + } +} + +public struct MessageRouteChip: View { + private let label: String + private let prefix: String + private let style: MessageRouteChipStyle + + public init( + label: String, + prefix: String = "@", + style: MessageRouteChipStyle + ) { + self.label = label + self.prefix = prefix + self.style = style + } + + public var body: some View { + Text(displayLabel) + .font(style.font) + .foregroundStyle(style.textColor) + .lineLimit(1) + .padding(.horizontal, style.horizontalPadding) + .padding(.vertical, style.verticalPadding) + .overlay( + RoundedRectangle(cornerRadius: style.cornerRadius, style: .continuous) + .stroke(style.borderColor, lineWidth: 0.5) + ) + .fixedSize() + } + + private var displayLabel: String { + if label.hasPrefix("@") || label.hasPrefix("#") { + return label + } + return prefix + label + } +} + +public struct MessageContextPillStyle: @unchecked Sendable { + let separatorFont: Font + let textFont: Font + let separatorColor: Color + let textColor: Color + + public init( + separatorFont: Font, + textFont: Font, + separatorColor: Color, + textColor: Color + ) { + self.separatorFont = separatorFont + self.textFont = textFont + self.separatorColor = separatorColor + self.textColor = textColor + } +} + +public struct MessageContextPill: View { + private let name: String + private let separator: String + private let style: MessageContextPillStyle + + public init( + name: String, + separator: String = "·", + style: MessageContextPillStyle + ) { + self.name = name + self.separator = separator + self.style = style + } + + public var body: some View { + HStack(spacing: 3) { + Text(separator) + .font(style.separatorFont) + .foregroundStyle(style.separatorColor) + Text(name) + .font(style.textFont) + .foregroundStyle(style.textColor) + .lineLimit(1) + } + .fixedSize() + } +} + +public struct MessageSendChipStyle: @unchecked Sendable { + let keyFont: Font + let titleFont: Font + let tracking: CGFloat + let enabledColor: Color + let hoverColor: Color + let disabledColor: Color + let horizontalPadding: CGFloat + let verticalPadding: CGFloat + + public init( + keyFont: Font, + titleFont: Font, + tracking: CGFloat, + enabledColor: Color, + hoverColor: Color, + disabledColor: Color, + horizontalPadding: CGFloat = 4, + verticalPadding: CGFloat = 2 + ) { + self.keyFont = keyFont + self.titleFont = titleFont + self.tracking = tracking + self.enabledColor = enabledColor + self.hoverColor = hoverColor + self.disabledColor = disabledColor + self.horizontalPadding = horizontalPadding + self.verticalPadding = verticalPadding + } +} + +public struct MessageSendChip: View { + private let isEnabled: Bool + private let isSending: Bool + private let keyGlyph: String + private let title: String + private let sendingTitle: String + private let style: MessageSendChipStyle + private let action: () -> Void + + @State private var hovered = false + + public init( + isEnabled: Bool, + isSending: Bool = false, + keyGlyph: String = "↵", + title: String = "SEND", + sendingTitle: String = "SENDING", + style: MessageSendChipStyle, + action: @escaping () -> Void + ) { + self.isEnabled = isEnabled + self.isSending = isSending + self.keyGlyph = keyGlyph + self.title = title + self.sendingTitle = sendingTitle + self.style = style + self.action = action + } + + public var body: some View { + Button(action: action) { + HStack(spacing: 4) { + Text(isSending ? "…" : keyGlyph) + .font(style.keyFont) + .foregroundStyle(color) + Text(isSending ? sendingTitle : title) + .font(style.titleFont) + .tracking(style.tracking) + .foregroundStyle(color) + } + .padding(.horizontal, style.horizontalPadding) + .padding(.vertical, style.verticalPadding) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(!isEnabled || isSending) + .onHover { hovered = $0 } + .help(isEnabled && !isSending ? "Send (↵)" : "") + } + + private var color: Color { + if !isEnabled || isSending { return style.disabledColor } + return hovered ? style.hoverColor : style.enabledColor + } +} diff --git a/apps/macos/Sources/ScoutSharedUI/MessageMarkupParser.swift b/apps/macos/Sources/ScoutSharedUI/MessageMarkupParser.swift new file mode 100644 index 00000000..4f12fb3d --- /dev/null +++ b/apps/macos/Sources/ScoutSharedUI/MessageMarkupParser.swift @@ -0,0 +1,215 @@ +import Foundation + +public struct MessageMarkupBlock: Identifiable, Equatable, Sendable { + public enum Kind: Equatable, Sendable { + case paragraph + case heading(depth: Int) + case rule + case list(ordered: Bool, items: [String]) + case blockquote + case code(language: String?) + case table(headers: [String], rows: [[String]]) + } + + public let id: Int + public let kind: Kind + public let text: String +} + +public enum MessageMarkupParser { + public static func parse(_ rawText: String) -> [MessageMarkupBlock] { + let normalized = normalize(rawText) + guard !normalized.isEmpty else { return [] } + + let lines = normalized + .replacingOccurrences(of: "\r\n", with: "\n") + .replacingOccurrences(of: "\r", with: "\n") + .components(separatedBy: "\n") + + var blocks: [MessageMarkupBlock] = [] + var index = 0 + var nextID = 0 + + func append(_ kind: MessageMarkupBlock.Kind, text: String = "") { + blocks.append(MessageMarkupBlock(id: nextID, kind: kind, text: text)) + nextID += 1 + } + + while index < lines.count { + let line = lines[index] + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty { + index += 1 + continue + } + + if let language = fenceLanguage(trimmed) { + var codeLines: [String] = [] + index += 1 + while index < lines.count && fenceLanguage(lines[index].trimmingCharacters(in: .whitespaces)) == nil { + codeLines.append(lines[index]) + index += 1 + } + if index < lines.count { index += 1 } + append(.code(language: language), text: codeLines.joined(separator: "\n")) + continue + } + + if isRule(trimmed) { + append(.rule) + index += 1 + continue + } + + if let heading = heading(trimmed) { + append(.heading(depth: heading.depth), text: heading.text) + index += 1 + continue + } + + if isTableStart(lines, index) { + let headers = splitTableRow(lines[index]) + index += 2 + var rows: [[String]] = [] + while index < lines.count, + lines[index].contains("|"), + !lines[index].trimmingCharacters(in: .whitespaces).isEmpty { + rows.append(splitTableRow(lines[index])) + index += 1 + } + append(.table(headers: headers, rows: rows)) + continue + } + + if let unordered = unorderedListItem(line) { + var items = [unordered] + index += 1 + while index < lines.count, let item = unorderedListItem(lines[index]) { + items.append(item) + index += 1 + } + append(.list(ordered: false, items: items)) + continue + } + + if let ordered = orderedListItem(line) { + var items = [ordered] + index += 1 + while index < lines.count, let item = orderedListItem(lines[index]) { + items.append(item) + index += 1 + } + append(.list(ordered: true, items: items)) + continue + } + + if trimmed.hasPrefix(">") { + var quoteLines: [String] = [] + while index < lines.count { + let quoteLine = lines[index].trimmingCharacters(in: .whitespaces) + guard quoteLine.hasPrefix(">") else { break } + quoteLines.append(String(quoteLine.dropFirst()).trimmingCharacters(in: .whitespaces)) + index += 1 + } + append(.blockquote, text: quoteLines.joined(separator: "\n")) + continue + } + + var paragraphLines: [String] = [] + while index < lines.count && !isBlockStart(lines, index) { + paragraphLines.append(lines[index].trimmingCharacters(in: .whitespaces)) + index += 1 + } + append(.paragraph, text: paragraphLines.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)) + } + + if blocks.isEmpty { + blocks.append(MessageMarkupBlock(id: 0, kind: .paragraph, text: rawText)) + } + return blocks + } + + private static func normalize(_ value: String) -> String { + value + .replacingOccurrences(of: "\r\n", with: "\n") + .replacingOccurrences(of: "\r", with: "\n") + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + private static func fenceLanguage(_ trimmed: String) -> String? { + guard trimmed.hasPrefix("```") else { return nil } + let language = String(trimmed.dropFirst(3)).trimmingCharacters(in: .whitespaces) + guard language.rangeOfCharacter(from: .whitespacesAndNewlines) == nil else { return nil } + return language.isEmpty ? "" : language + } + + private static func heading(_ trimmed: String) -> (depth: Int, text: String)? { + let depth = trimmed.prefix { $0 == "#" }.count + guard (1...6).contains(depth) else { return nil } + let rest = trimmed.dropFirst(depth) + guard rest.first == " " else { return nil } + return (depth, String(rest.dropFirst()).trimmingCharacters(in: .whitespaces)) + } + + private static func isRule(_ trimmed: String) -> Bool { + guard trimmed.count >= 3 else { return false } + let allowed = Set(trimmed) + return allowed == ["-"] || allowed == ["*"] || allowed == ["_"] + } + + private static func unorderedListItem(_ line: String) -> String? { + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard trimmed.hasPrefix("- ") || trimmed.hasPrefix("* ") else { return nil } + return String(trimmed.dropFirst(2)).trimmingCharacters(in: .whitespaces) + } + + private static func orderedListItem(_ line: String) -> String? { + let trimmed = line.trimmingCharacters(in: .whitespaces) + let digits = trimmed.prefix { $0.isNumber } + guard !digits.isEmpty else { return nil } + let rest = trimmed.dropFirst(digits.count) + guard rest.count >= 2, + let marker = rest.first, + marker == "." || marker == ")", + rest.dropFirst().first == " " + else { return nil } + return String(rest.dropFirst(2)).trimmingCharacters(in: .whitespaces) + } + + private static func splitTableRow(_ line: String) -> [String] { + var trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix("|") { trimmed.removeFirst() } + if trimmed.hasSuffix("|") { trimmed.removeLast() } + return trimmed + .split(separator: "|", omittingEmptySubsequences: false) + .map { String($0).trimmingCharacters(in: .whitespaces) } + } + + private static func isTableSeparator(_ line: String) -> Bool { + let cells = splitTableRow(line) + guard cells.count >= 2 else { return false } + return cells.allSatisfy { cell in + let core = cell.trimmingCharacters(in: CharacterSet(charactersIn: " :-")) + return core.isEmpty && cell.contains("-") + } + } + + private static func isTableStart(_ lines: [String], _ index: Int) -> Bool { + guard index + 1 < lines.count else { return false } + return lines[index].contains("|") && isTableSeparator(lines[index + 1]) + } + + private static func isBlockStart(_ lines: [String], _ index: Int) -> Bool { + guard index < lines.count else { return true } + let line = lines[index] + let trimmed = line.trimmingCharacters(in: .whitespaces) + return trimmed.isEmpty + || fenceLanguage(trimmed) != nil + || heading(trimmed) != nil + || isRule(trimmed) + || unorderedListItem(line) != nil + || orderedListItem(line) != nil + || trimmed.hasPrefix(">") + || isTableStart(lines, index) + } +} diff --git a/apps/macos/Sources/ScoutSharedUI/MessageSuggestionPopover.swift b/apps/macos/Sources/ScoutSharedUI/MessageSuggestionPopover.swift new file mode 100644 index 00000000..cb80e257 --- /dev/null +++ b/apps/macos/Sources/ScoutSharedUI/MessageSuggestionPopover.swift @@ -0,0 +1,159 @@ +import SwiftUI + +public struct MessageSuggestionPopoverStyle: @unchecked Sendable { + let eyebrowFont: Font + let markFont: Font + let labelFont: Font + let detailFont: Font + let eyebrowColor: Color + let commandAccent: Color + let agentAccent: Color + let sessionAccent: Color + let selectedLabelColor: Color + let labelColor: Color + let detailColor: Color + let selectedBackgroundColor: Color + let backgroundColor: Color + let borderColor: Color + let shadowColor: Color + let cornerRadius: CGFloat + let borderWidth: CGFloat + + public init( + eyebrowFont: Font, + markFont: Font, + labelFont: Font, + detailFont: Font, + eyebrowColor: Color, + commandAccent: Color, + agentAccent: Color, + sessionAccent: Color, + selectedLabelColor: Color, + labelColor: Color, + detailColor: Color, + selectedBackgroundColor: Color, + backgroundColor: Color, + borderColor: Color, + shadowColor: Color, + cornerRadius: CGFloat = 6, + borderWidth: CGFloat = 0.75 + ) { + self.eyebrowFont = eyebrowFont + self.markFont = markFont + self.labelFont = labelFont + self.detailFont = detailFont + self.eyebrowColor = eyebrowColor + self.commandAccent = commandAccent + self.agentAccent = agentAccent + self.sessionAccent = sessionAccent + self.selectedLabelColor = selectedLabelColor + self.labelColor = labelColor + self.detailColor = detailColor + self.selectedBackgroundColor = selectedBackgroundColor + self.backgroundColor = backgroundColor + self.borderColor = borderColor + self.shadowColor = shadowColor + self.cornerRadius = cornerRadius + self.borderWidth = borderWidth + } + + func accent(for kind: MessageSuggestionKind) -> Color { + switch kind { + case .command: return commandAccent + case .agent: return agentAccent + case .session: return sessionAccent + } + } +} + +public struct MessageSuggestionPopover: View { + private let suggestions: [MessageSuggestion] + private let selectedIndex: Int + private let style: MessageSuggestionPopoverStyle + private let onHover: (Int) -> Void + private let onSelect: (MessageSuggestion) -> Void + + public init( + suggestions: [MessageSuggestion], + selectedIndex: Int, + style: MessageSuggestionPopoverStyle, + onHover: @escaping (Int) -> Void, + onSelect: @escaping (MessageSuggestion) -> Void + ) { + self.suggestions = suggestions + self.selectedIndex = selectedIndex + self.style = style + self.onHover = onHover + self.onSelect = onSelect + } + + public var body: some View { + VStack(alignment: .leading, spacing: 0) { + Text(suggestions.first?.kind.eyebrow ?? "SUGGEST") + .font(style.eyebrowFont) + .foregroundStyle(style.eyebrowColor) + .padding(.horizontal, 10) + .padding(.top, 7) + .padding(.bottom, 4) + + ForEach(Array(suggestions.prefix(7).enumerated()), id: \.element.id) { index, suggestion in + Button { + onSelect(suggestion) + } label: { + row(suggestion: suggestion, selected: index == selectedIndex) + } + .buttonStyle(.plain) + .onHover { hovering in + if hovering { onHover(index) } + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: style.cornerRadius, style: .continuous) + .fill(style.backgroundColor) + ) + .overlay( + RoundedRectangle(cornerRadius: style.cornerRadius, style: .continuous) + .stroke(style.borderColor, lineWidth: style.borderWidth) + ) + .shadow(color: style.shadowColor, radius: 10, x: 0, y: 4) + } + + private func row(suggestion: MessageSuggestion, selected: Bool) -> some View { + let accent = style.accent(for: suggestion.kind) + return HStack(spacing: 9) { + Text(suggestion.kind.mark) + .font(style.markFont) + .foregroundStyle(selected ? accent : style.eyebrowColor) + .frame(width: 24, alignment: .leading) + + VStack(alignment: .leading, spacing: 1) { + Text(suggestion.label) + .font(style.labelFont) + .foregroundStyle(selected ? style.selectedLabelColor : style.labelColor) + .lineLimit(1) + .truncationMode(.middle) + Text(suggestion.detail) + .font(style.detailFont) + .foregroundStyle(style.detailColor) + .lineLimit(1) + .truncationMode(.tail) + } + Spacer(minLength: 0) + } + .padding(.horizontal, 10) + .padding(.vertical, 5) + .frame(maxWidth: .infinity, minHeight: 38, alignment: .leading) + .background( + Rectangle() + .fill(selected ? style.selectedBackgroundColor : Color.clear) + ) + .overlay(alignment: .leading) { + Rectangle() + .fill(selected ? accent : Color.clear) + .frame(width: 1.5) + } + .contentShape(Rectangle()) + } +} diff --git a/apps/macos/Sources/ScoutSharedUI/MessageSuggestions.swift b/apps/macos/Sources/ScoutSharedUI/MessageSuggestions.swift new file mode 100644 index 00000000..396c6d89 --- /dev/null +++ b/apps/macos/Sources/ScoutSharedUI/MessageSuggestions.swift @@ -0,0 +1,322 @@ +import Foundation + +public enum MessageSuggestionKind: String, Sendable { + case command + case agent + case session + + public var eyebrow: String { + switch self { + case .command: return "COMMANDS" + case .agent: return "AGENTS" + case .session: return "SESSIONS" + } + } + + public var mark: String { + switch self { + case .command: return "/" + case .agent: return "@" + case .session: return "sid" + } + } +} + +public enum MessageSuggestionAction: String, Sendable { + case openRunner +} + +public struct MessageSuggestion: Identifiable, Equatable, Sendable { + public let id: String + public let kind: MessageSuggestionKind + public let label: String + public let detail: String + public let replacement: String + public let targetHandle: String? + public let targetLabel: String? + public let action: MessageSuggestionAction? + + public init( + id: String, + kind: MessageSuggestionKind, + label: String, + detail: String, + replacement: String, + targetHandle: String?, + targetLabel: String?, + action: MessageSuggestionAction? + ) { + self.id = id + self.kind = kind + self.label = label + self.detail = detail + self.replacement = replacement + self.targetHandle = targetHandle + self.targetLabel = targetLabel + self.action = action + } +} + +public struct MessageSuggestionTrigger: Equatable, Sendable { + public let kind: MessageSuggestionKind + public let token: String + public let query: String + public let startOffset: Int + public let endOffset: Int + + public var signature: String { + "\(kind.rawValue):\(startOffset):\(token)" + } + + public init( + kind: MessageSuggestionKind, + token: String, + query: String, + startOffset: Int, + endOffset: Int + ) { + self.kind = kind + self.token = token + self.query = query + self.startOffset = startOffset + self.endOffset = endOffset + } +} + +public struct MessageCommandCandidate: Sendable { + public let command: String + public let detail: String + public let replacement: String + public let action: MessageSuggestionAction? + + public init( + command: String, + detail: String, + replacement: String, + action: MessageSuggestionAction? = nil + ) { + self.command = command + self.detail = detail + self.replacement = replacement + self.action = action + } +} + +public struct MessageSuggestionAgent: Sendable { + public let id: String + public let name: String + public let handle: String? + public let state: String + public let role: String? + public let workspaceRoot: String? + public let harnessSessionId: String? + + public init( + id: String, + name: String, + handle: String?, + state: String, + role: String?, + workspaceRoot: String?, + harnessSessionId: String? + ) { + self.id = id + self.name = name + self.handle = handle + self.state = state + self.role = role + self.workspaceRoot = workspaceRoot + self.harnessSessionId = harnessSessionId + } +} + +public enum MessageSuggestionEngine { + public static let defaultCommands: [MessageCommandCandidate] = [ + MessageCommandCandidate(command: "/help", detail: "Show Scoutbot commands", replacement: "/help "), + MessageCommandCandidate(command: "/agents", detail: "List known agents and endpoints", replacement: "/agents "), + MessageCommandCandidate(command: "/status", detail: "Summarize active work and online agents", replacement: "/status "), + MessageCommandCandidate(command: "/recent", detail: "Show recent messages from an agent", replacement: "/recent "), + MessageCommandCandidate(command: "/doing", detail: "Show active work for an agent", replacement: "/doing "), + MessageCommandCandidate(command: "/flight", detail: "Inspect a flight by id", replacement: "/flight "), + MessageCommandCandidate(command: "/steer", detail: "Target this thread at a session", replacement: "/steer sid:"), + MessageCommandCandidate(command: "/spin", detail: "Open the agent runner", replacement: "", action: .openRunner), + ] + + public static func detectTrigger(in value: String) -> MessageSuggestionTrigger? { + guard !value.isEmpty else { return nil } + let end = value.endIndex + var start = end + while start > value.startIndex { + let previous = value.index(before: start) + if value[previous].isWhitespace { + break + } + start = previous + } + + let token = String(value[start.. [MessageSuggestion] { + switch trigger.kind { + case .command: + return commandSuggestions(query: trigger.query, commands: commands) + case .agent: + return agentSuggestions(query: trigger.query, agents: agents) + case .session: + return sessionSuggestions(query: trigger.query, agents: agents) + } + } + + public static func index(in value: String, offset: Int) -> String.Index? { + guard offset >= 0, offset <= value.count else { return nil } + return value.index(value.startIndex, offsetBy: offset, limitedBy: value.endIndex) + } + + private static func commandSuggestions(query: String, commands: [MessageCommandCandidate]) -> [MessageSuggestion] { + let q = query.lowercased() + return commands + .filter { candidate in + q.isEmpty + || candidate.command.dropFirst().lowercased().hasPrefix(q) + || candidate.command.lowercased().contains(q) + || candidate.detail.lowercased().contains(q) + } + .prefix(8) + .map { candidate in + MessageSuggestion( + id: "command:\(candidate.command)", + kind: .command, + label: candidate.command, + detail: candidate.detail, + replacement: candidate.replacement, + targetHandle: nil, + targetLabel: nil, + action: candidate.action + ) + } + } + + private static func agentSuggestions(query: String, agents: [MessageSuggestionAgent]) -> [MessageSuggestion] { + let q = query.lowercased() + var seen = Set() + return agents + .compactMap { agent -> MessageSuggestion? in + guard let handle = suggestionHandle(for: agent) else { return nil } + let key = handle.lowercased() + guard !seen.contains(key) else { return nil } + guard q.isEmpty + || handle.lowercased().contains(q) + || agent.name.lowercased().contains(q) + || agent.id.lowercased().contains(q) else { + return nil + } + seen.insert(key) + return MessageSuggestion( + id: "agent:\(key)", + kind: .agent, + label: "@\(handle)", + detail: agentSuggestionDetail(agent), + replacement: "", + targetHandle: handle, + targetLabel: agent.name, + action: nil + ) + } + .sorted { $0.label.localizedCaseInsensitiveCompare($1.label) == .orderedAscending } + .prefix(7) + .map { $0 } + } + + private static func sessionSuggestions(query: String, agents: [MessageSuggestionAgent]) -> [MessageSuggestion] { + let q = query.lowercased() + var seen = Set() + return agents + .compactMap { agent -> MessageSuggestion? in + guard let sessionId = agent.harnessSessionId?.trimmingCharacters(in: .whitespacesAndNewlines), + !sessionId.isEmpty else { + return nil + } + let key = sessionId.lowercased() + guard !seen.contains(key) else { return nil } + guard q.isEmpty + || key.contains(q) + || agent.name.lowercased().contains(q) + || (agent.handle ?? "").lowercased().contains(q) else { + return nil + } + seen.insert(key) + return MessageSuggestion( + id: "session:\(key)", + kind: .session, + label: "sid:\(sessionId)", + detail: "\(agent.name) · \(agent.state)", + replacement: "sid:\(sessionId) ", + targetHandle: nil, + targetLabel: nil, + action: nil + ) + } + .sorted { $0.label.localizedCaseInsensitiveCompare($1.label) == .orderedAscending } + .prefix(7) + .map { $0 } + } + + private static func suggestionHandle(for agent: MessageSuggestionAgent) -> String? { + let raw = agent.handle ?? agent.name + let trimmed = raw + .trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: CharacterSet(charactersIn: "@")) + return trimmed.isEmpty ? nil : trimmed + } + + private static func agentSuggestionDetail(_ agent: MessageSuggestionAgent) -> String { + let scope = (agent.workspaceRoot as NSString?)?.lastPathComponent ?? agent.role + return "\(agent.name) · \(agent.state) · \(scope ?? "agent")" + } + + private static func isSimpleQuery(_ value: String) -> Bool { + value.allSatisfy { ch in + ch.isLetter || ch.isNumber || ch == "-" || ch == "_" + } + } + + private static func isHandleQuery(_ value: String) -> Bool { + value.allSatisfy { ch in + ch.isLetter || ch.isNumber || ch == "-" || ch == "_" || ch == "." + } + } + + private static func isSessionQuery(_ value: String) -> Bool { + value.allSatisfy { ch in + ch.isLetter || ch.isNumber || ch == "-" || ch == "_" || ch == "." + } + } +} diff --git a/apps/macos/Sources/Services/HudVoxService.swift b/apps/macos/Sources/ScoutSharedUI/ScoutVoxService.swift similarity index 95% rename from apps/macos/Sources/Services/HudVoxService.swift rename to apps/macos/Sources/ScoutSharedUI/ScoutVoxService.swift index 4a9259d8..91726246 100644 --- a/apps/macos/Sources/Services/HudVoxService.swift +++ b/apps/macos/Sources/ScoutSharedUI/ScoutVoxService.swift @@ -17,15 +17,15 @@ import ScoutNativeCore /// again to commit and surface the final transcript on `lastFinalText`. /// ESC cascade in the dock calls `cancel()` to abandon. @MainActor -final class HudVoxService: ObservableObject { - static let shared = HudVoxService() +public final class ScoutVoxService: ObservableObject { + public static let shared = ScoutVoxService() - @Published private(set) var state: ScoutDictationState = .probing + @Published public private(set) var state: ScoutDictationState = .probing /// Most recent partial transcript while recording. Cleared on stop. - @Published private(set) var partial: String = "" + @Published public private(set) var partial: String = "" /// Most recent final transcript. The dock observes this via Combine /// and appends it to the text buffer once it transitions to non-empty. - @Published private(set) var lastFinalText: String = "" + @Published public private(set) var lastFinalText: String = "" private let baseURL = URL(string: "http://127.0.0.1:43115")! private let clientId = "openscout-hud" @@ -45,7 +45,7 @@ final class HudVoxService: ObservableObject { /// Check whether the companion is reachable. Updates `state` to /// `.idle` if healthy, `.unavailable(reason:)` otherwise. - func probe() async { + public func probe() async { state = .probing var req = URLRequest(url: baseURL.appendingPathComponent("health")) req.timeoutInterval = 1.2 @@ -73,7 +73,7 @@ final class HudVoxService: ObservableObject { /// Open a live transcription session. The companion starts capturing /// from its own microphone; we read NDJSON events from the response /// body until the session emits `session.final` (or errors out). - func start() { + public func start() { switch state { case .recording, .starting, .processing: log.info("start() ignored — already \(String(describing: self.state))") @@ -95,7 +95,7 @@ final class HudVoxService: ObservableObject { /// Commit the in-flight session — companion finalizes and emits /// `session.final`. We move to `.processing` until the final event /// lands (or the stream closes). - func stop() { + public func stop() { guard state == .recording || state == .starting else { return } state = .processing let target = sessionId @@ -106,7 +106,7 @@ final class HudVoxService: ObservableObject { /// Abort the in-flight session without surfacing a transcript. /// Safe to call from any state. - func cancel() { + public func cancel() { streamTask?.cancel() streamTask = nil let target = sessionId @@ -125,7 +125,7 @@ final class HudVoxService: ObservableObject { /// Reset `lastFinalText` after the consumer (the dock) has appended /// it to its buffer, so we don't re-fire on the next subscription /// or duplicate the transcript across sessions. - func consumeFinalText() { + public func consumeFinalText() { lastFinalText = "" } @@ -250,3 +250,5 @@ final class HudVoxService: ObservableObject { _ = try? await URLSession.shared.data(for: req) } } + +public typealias HudVoxService = ScoutVoxService diff --git a/apps/macos/bin/scout-app.ts b/apps/macos/bin/scout-app.ts new file mode 100755 index 00000000..a3c88b01 --- /dev/null +++ b/apps/macos/bin/scout-app.ts @@ -0,0 +1,204 @@ +#!/usr/bin/env bun + +import { execSync, spawn } from "node:child_process"; +import { + chmodSync, + cpSync, + existsSync, + mkdirSync, + readFileSync, + rmSync, +} from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const appDir = resolve(scriptDir, ".."); +const repoRoot = resolve(appDir, "..", ".."); +const distDir = resolve(appDir, "dist"); +const bundleName = "Scout.app"; +const bundlePath = resolve(distDir, bundleName); +const binaryDir = resolve(bundlePath, "Contents", "MacOS"); +const binaryPath = resolve(binaryDir, "Scout"); +const resourcesDir = resolve(bundlePath, "Contents", "Resources"); +const infoPlistTemplate = resolve(appDir, "ScoutInfo.plist"); +const iconSource = resolve(repoRoot, "apps", "desktop", "public", "scout-icon.png"); +const packageJsonPath = resolve(repoRoot, "package.json"); + +type BuildMode = "dev" | "build"; +type Command = + | "dev" + | "dev-build" + | "build" + | "build-restart" + | "launch" + | "start" + | "restart" + | "quit" + | "stop" + | "status" + | "help"; + +function printHelp(): void { + console.log(`scout-app — standalone Scout macOS app + +Usage: + bun apps/macos/bin/scout-app.ts dev # local Hudson path + debug build + relaunch + bun apps/macos/bin/scout-app.ts dev-build # local Hudson path + debug build only + bun apps/macos/bin/scout-app.ts build # git Hudson + release build only + bun apps/macos/bin/scout-app.ts build-restart # git Hudson + release build + relaunch + bun apps/macos/bin/scout-app.ts launch # launch existing bundle + bun apps/macos/bin/scout-app.ts restart # alias: dev + bun apps/macos/bin/scout-app.ts quit + bun apps/macos/bin/scout-app.ts status +`); +} + +function appVersion(): string { + try { + const parsed = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { version?: string }; + if (parsed.version?.trim()) return parsed.version.trim(); + } catch { + // ignore + } + return "0.1.0"; +} + +function swiftConfiguration(mode: BuildMode): "debug" | "release" { + return mode === "dev" ? "debug" : "release"; +} + +function modeLabel(mode: BuildMode): string { + return mode === "dev" ? "dev" : "build"; +} + +function swiftBuildEnvironment(mode: BuildMode): NodeJS.ProcessEnv { + return { + ...process.env, + OPENSCOUT_HUDSON_SOURCE: mode === "dev" ? "path" : "git", + }; +} + +function buildSwift(mode: BuildMode): string { + const configuration = swiftConfiguration(mode); + console.log(`Building Scout ${modeLabel(mode)} bundle...`); + execSync(`swift build -c ${configuration} --product Scout`, { + cwd: appDir, + env: swiftBuildEnvironment(mode), + stdio: "inherit", + }); + return execSync(`swift build -c ${configuration} --show-bin-path`, { + cwd: appDir, + env: swiftBuildEnvironment(mode), + stdio: "pipe", + }).toString("utf8").trim(); +} + +function writeIcon(): void { + if (!existsSync(iconSource)) return; + const iconset = resolve(distDir, "Scout.iconset"); + rmSync(iconset, { recursive: true, force: true }); + mkdirSync(iconset, { recursive: true }); + + const sizes = [16, 32, 64, 128, 256, 512, 1024]; + for (const size of sizes) { + const out = join(iconset, `icon_${size}x${size}.png`); + execSync(`sips -z ${size} ${size} '${iconSource}' --out '${out}' >/dev/null`); + if (size <= 512) { + const retina = join(iconset, `icon_${size}x${size}@2x.png`); + execSync(`sips -z ${size * 2} ${size * 2} '${iconSource}' --out '${retina}' >/dev/null`); + } + } + + execSync(`iconutil -c icns '${iconset}' -o '${join(resourcesDir, "AppIcon.icns")}'`); + rmSync(iconset, { recursive: true, force: true }); + execSync(`/usr/libexec/PlistBuddy -c "Add :CFBundleIconFile string AppIcon" '${join(bundlePath, "Contents", "Info.plist")}'`, { + stdio: "ignore", + }); +} + +function bundleApp(mode: BuildMode): void { + const binPath = buildSwift(mode); + const builtBinary = join(binPath, "Scout"); + if (!existsSync(builtBinary)) { + throw new Error(`Built Scout binary not found: ${builtBinary}`); + } + + rmSync(bundlePath, { recursive: true, force: true }); + mkdirSync(binaryDir, { recursive: true }); + mkdirSync(resourcesDir, { recursive: true }); + + cpSync(builtBinary, binaryPath); + chmodSync(binaryPath, 0o755); + cpSync(infoPlistTemplate, join(bundlePath, "Contents", "Info.plist")); + + const version = appVersion(); + execSync(`/usr/libexec/PlistBuddy -c "Set :CFBundleVersion ${version}" '${join(bundlePath, "Contents", "Info.plist")}'`); + execSync(`/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString ${version}" '${join(bundlePath, "Contents", "Info.plist")}'`); + + writeIcon(); + execSync(`codesign --force --deep --sign - '${bundlePath}'`, { stdio: "inherit" }); + console.log(`Built ${bundlePath} (${modeLabel(mode)})`); +} + +function isRunning(): boolean { + try { + execSync("pgrep -x Scout", { stdio: "ignore" }); + return true; + } catch { + return false; + } +} + +function quit(): void { + if (!isRunning()) return; + execSync("pkill -x Scout", { stdio: "ignore" }); +} + +function launch(): void { + if (!existsSync(bundlePath)) bundleApp("dev"); + spawn("open", [bundlePath], { + detached: true, + stdio: "ignore", + }).unref(); +} + +function restart(mode: BuildMode): void { + quit(); + bundleApp(mode); + launch(); +} + +const command = (process.argv[2] ?? "help") as Command; + +switch (command) { + case "dev-build": + bundleApp("dev"); + break; + case "dev": + case "restart": + restart("dev"); + break; + case "build": + bundleApp("build"); + break; + case "build-restart": + restart("build"); + break; + case "launch": + case "start": + launch(); + break; + case "quit": + case "stop": + quit(); + break; + case "status": + console.log(isRunning() ? "Scout is running." : "Scout is not running."); + break; + case "help": + printHelp(); + break; + default: + throw new Error(`Unknown command: ${command}`); +} diff --git a/docs/eng/releasing.md b/docs/eng/releasing.md index ac605f37..45294688 100644 --- a/docs/eng/releasing.md +++ b/docs/eng/releasing.md @@ -22,7 +22,7 @@ Before your first release, make sure the following are set up locally. - Preferred: configure npm trusted publishing for `@openscout/scout`: - owner/repo: `arach/openscout` - - workflow filename: `npm-publish.yml` + - workflow filename: `release-package-npm.yml` - allowed action: `npm publish` - Fallback: add a GitHub repository secret named `NPM_TOKEN` with publish access. @@ -105,8 +105,9 @@ npm run ship -- 0.2.68 --execute --yes --github-npm ``` This verifies the internal builds and packed public manifest locally, pushes the -tag, creates the GitHub release, then dispatches `.github/workflows/npm-publish.yml` -to publish `@openscout/scout` from the release tag. +tag, creates the GitHub release, then dispatches +`.github/workflows/release-package-npm.yml` to publish `@openscout/scout` from +the release tag. For manual recovery or an emergency local publish, the lower-level helper still builds the internal packages, verifies the packed public manifest, and publishes @@ -170,6 +171,28 @@ bun apps/macos/bin/openscout-menu.ts dmg The shell script stays the source of truth for release builds because it fails fast when signing or notarization is misconfigured. +### GitHub Actions app release + +Tag `app-macos-v` to build and upload the signed, notarized DMG from +CI. The workflow expects these repository secrets: + +- `MACOS_DEVELOPER_ID_APPLICATION_P12_BASE64` +- `MACOS_DEVELOPER_ID_APPLICATION_P12_PASSWORD` +- `MACOS_RELEASE_KEYCHAIN_PASSWORD` +- `APPLE_ID` +- `APPLE_TEAM_ID` +- `APPLE_APP_SPECIFIC_PASSWORD` + +Optional repository variables: + +- `OPENSCOUT_SIGN_IDENTITY` + +Tag `app-ios-v` to run the iOS App Store Connect upload. The tag +version must match the root `package.json` version because +`apps/ios/scripts/release.sh` uses that manifest as the iOS marketing version. +The runner must have the `asc` CLI available, or `OPENSCOUT_ASC_BIN` must point +to it. `OPENSCOUT_ASC_APP_ID` can override the default App Store app id. + ## After publishing - **Push the bump commit**: `git push origin main`. diff --git a/docs/eng/sco-060-comms-channel-primitive-and-adapter-destinations.md b/docs/eng/sco-060-comms-channel-primitive-and-adapter-destinations.md new file mode 100644 index 00000000..84cb0336 --- /dev/null +++ b/docs/eng/sco-060-comms-channel-primitive-and-adapter-destinations.md @@ -0,0 +1,91 @@ +# SCO-060: Comms Channel Primitive and Adapter Destinations + +## 1. Status + +- **Status:** Draft +- **Owner:** OpenScout +- **Scope:** Comms identity, native Comms shell, adapter-ready routing +- **Intent:** Put DMs and named shared lanes on one durable top-level channel primitive, with `cId` as the stable identifier. + +## 2. Summary + +The product direction is a standalone native **Comms** surface: close to the HUD in navigational pull, but cleaner and focused on communication. It should not become another HUD slot, a settings home, or a web wrapper. + +The model direction is also simple: a DM is a channel with a small member set and private defaults. A named shared lane is a channel with an alias, broader visibility, and different notification policy. Adding members can change the channel's treatment without changing its `cId`. + +`cId` means **channel id**. It is intentionally compact enough to stay usable in UI, logs, and API payloads. + +## 3. External Shape + +| System | Top-Level Unit | DM Model | Nested Discussion | Useful Takeaway | +| --- | --- | --- | --- | --- | +| Slack | Unified messaging container | 1:1 and group DMs use the same top-level API family as shared lanes | Message replies under a parent timestamp | Unified row model is proven. | +| Microsoft Teams | Chat or team channel | Chat covers 1:1 and group DMs | Reply chains inside channel posts | Membership and policy vary more than identity. | +| Matrix | Room | DMs are rooms with direct-chat metadata | Relations and replies | Room/channel identity is stable while semantics shift. | +| Zulip | Stream plus topic | Direct messages are separate | Topic is the main sub-lane | Topics may be useful later, but not as the root unit. | +| Vercel Chat SDK | Channel + thread + adapter | DM open calls return an active unit | Thread is the active bot lane | Adapter thinking is useful; nouns do not need to match. | + +## 4. Nouns + +| Term | Meaning | +| --- | --- | +| **Comms** | The native communication surface. | +| **Channel** | Durable top-level communication unit. DMs, group DMs, named shared lanes, and system lanes all fit here. | +| **cId** | Stable channel id. Prefer opaque `c.` ids for new rows. | +| **Alias** | Optional display handle such as `#talkie-next`. | +| **Member** | Actor allowed to participate or receive events under policy. | +| **Thread** | Nested discussion rooted at a message inside a channel. | +| **Adapter** | Bridge to an external destination such as Slack, Teams, Matrix, email, or a future A2A surface. | +| **Binding** | Mapping between one Scout channel and one adapter destination. | +| **Policy** | Visibility, notification, retention, and member mutation rules. | + +## 5. Shape + +```ts +type Channel = { + cId: `c.${string}`; + kind: "direct" | "group" | "shared" | "system"; + alias?: string; + title: string; + memberIds: string[]; + policy: ChannelPolicy; + metadata?: Record; +}; + +type ChannelPolicy = { + visibility: "private" | "workspace" | "public" | "system"; + shareMode: "local" | "shared"; + discoverability: "hidden" | "listed"; + notifications: "direct" | "mentions" | "all" | "muted"; + memberMutation: "locked" | "owner" | "members"; +}; + +type AdapterBinding = { + id: string; + cId: Channel["cId"]; + adapter: "slack" | "teams" | "matrix" | "email" | "a2a"; + destinationId: string; + policy?: Partial; +}; +``` + +## 6. Product Rules + +1. The native app uses **Comms**, **channel**, and **cId**. +2. `cId` is stable across member, alias, and policy changes. +3. A private 1:1 can become group-like by adding members. +4. A shared lane becomes channel-like by alias and policy, not by a different root type. +5. Threads are nested under messages and do not own the top-level identity. +6. Adapter destination ids live in bindings, never inside `cId`. + +## 7. Native First Slice + +- Add a standalone native Comms panel, invoked from the menu bar and Hyper+C. +- Use a unified rail for private and shared channels. +- Show `cId` as the identity chip. +- Keep the visual treatment clean, immersive, and communication-centered. +- Avoid add-member controls in this slice; the app shape matters first. + +## 8. Compatibility Boundary + +Existing runtime and web routes may still expose older field names while the repo moves forward. New Comms-facing surfaces should use `cId`, channel vocabulary, and `/api/comms`-style aliases. Treat older names as compatibility baggage, not product ontology. diff --git a/docs/releases.md b/docs/releases.md index 56d7c28e..199d18be 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -39,12 +39,12 @@ npm run ship -- 0.2.64 --execute --yes --github-npm Use `--github-npm` for the normal public release path. It still runs the npm dry-run build locally before tagging, but the real publish happens when the -release helper dispatches `.github/workflows/npm-publish.yml` after creating the -GitHub release. +release helper dispatches `.github/workflows/release-package-npm.yml` after +creating the GitHub release. Configure npm trusted publishing for package `@openscout/scout` with: - owner/repo: `arach/openscout` -- workflow filename: `npm-publish.yml` +- workflow filename: `release-package-npm.yml` - allowed action: `npm publish` If trusted publishing is not configured yet, add a GitHub repository secret @@ -53,3 +53,15 @@ named `NPM_TOKEN` with publish access; the workflow supports that as a fallback. The script refuses to execute from a dirty worktree unless `--allow-dirty` is passed. Prefer a clean release branch so the `Release v` commit and tag represent exactly what was shipped. + +## GitHub Actions release lanes + +Routine pull requests run the Linux typecheck/unit workflow only. Merges to +`main` do not run CI unless they deploy the landing site. + +- `candidate-v*` runs `Release Candidate`: Linux checks, native macOS/iOS + simulator checks, package dry-runs, and runtime scenarios. +- `npm-v*` runs `Release Package npm` and publishes `@openscout/scout`. +- `app-macos-v*` runs `Release App macOS` and builds/uploads the signed, + notarized DMG. +- `app-ios-v*` runs `Release App iOS` and uploads through App Store Connect. diff --git a/packages/protocol/src/channel-identity.ts b/packages/protocol/src/channel-identity.ts new file mode 100644 index 00000000..fe73283a --- /dev/null +++ b/packages/protocol/src/channel-identity.ts @@ -0,0 +1,47 @@ +import type { MetadataMap, ScoutId } from "./common.js"; + +export const CHANNEL_ID_PREFIX = "c."; +export const CHANNEL_NATURAL_KEY_METADATA = "naturalKey"; +export const CHANNEL_LEGACY_ID_METADATA = "legacyId"; + +export function mintChannelId(randomUuid: () => string): ScoutId { + return `${CHANNEL_ID_PREFIX}${randomUuid().toLowerCase()}`; +} + +export function directChannelNaturalKey(participantIds: ScoutId[]): string { + return `direct:${stableIdentityParts(participantIds).join(",")}`; +} + +export function namedChannelNaturalKey(channel: string): string { + return `channel:${encodeIdentityPart(channel.trim().toLowerCase() || "shared")}`; +} + +export function systemChannelNaturalKey(name: string): string { + return `system:${encodeIdentityPart(name.trim().toLowerCase() || "system")}`; +} + +export function channelNaturalKeyFromMetadata( + metadata: MetadataMap | undefined, +): string | null { + const value = metadata?.[CHANNEL_NATURAL_KEY_METADATA]; + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +export function channelLegacyIdFromMetadata( + metadata: MetadataMap | undefined, +): string | null { + const value = metadata?.[CHANNEL_LEGACY_ID_METADATA]; + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function stableIdentityParts(values: ScoutId[]): string[] { + return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean))) + .sort() + .map(encodeIdentityPart); +} + +function encodeIdentityPart(value: string): string { + return encodeURIComponent(value).replace(/[!'()*]/g, (char) => + `%${char.charCodeAt(0).toString(16).toUpperCase()}`, + ); +} diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts index 8a21af79..609cdc28 100644 --- a/packages/protocol/src/index.ts +++ b/packages/protocol/src/index.ts @@ -8,6 +8,7 @@ export * from "./scout-agent-card.js"; export * from "./relay-agent-card.js"; export * from "./mesh.js"; export * from "./conversations.js"; +export * from "./channel-identity.js"; export * from "./collaboration.js"; export * from "./unblock-requests.js"; export * from "./messages.js"; diff --git a/packages/runtime/src/broker-daemon.test.ts b/packages/runtime/src/broker-daemon.test.ts index 402f9e56..36749f64 100644 --- a/packages/runtime/src/broker-daemon.test.ts +++ b/packages/runtime/src/broker-daemon.test.ts @@ -1406,6 +1406,23 @@ describe("broker daemon comms layer", () => { expect( events.some((event) => event.kind === "flight.updated" && event.payload.flight?.targetAgentId === "ghost" && event.payload.flight?.state === "waking"), ).toBe(true); + + const snapshot = await waitFor( + () => getJson<{ + flights: Record; + }>; + }>(harness.baseUrl, "/v1/snapshot"), + (value) => value.flights[response.flightId]?.state === "queued", + ); + const flight = snapshot.flights[response.flightId]; + expect(flight?.summary).toBe("Message stored for Ghost. Will deliver when online."); + expect(flight?.metadata?.dispatchOutcome).toEqual(expect.objectContaining({ + status: "queued_until_online", + reason: "no_runnable_endpoint", + })); }, 15_000); test("keeps replayed invocations available to daemon routes after restart", async () => { diff --git a/packages/runtime/src/broker-daemon.ts b/packages/runtime/src/broker-daemon.ts index 151a5011..3c9c7c2c 100644 --- a/packages/runtime/src/broker-daemon.ts +++ b/packages/runtime/src/broker-daemon.ts @@ -1,4 +1,5 @@ import { spawn, type ChildProcess } from "node:child_process"; +import { randomUUID } from "node:crypto"; import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import { existsSync, mkdirSync, openSync } from "node:fs"; import { lstat, mkdir, stat, unlink } from "node:fs/promises"; @@ -18,6 +19,8 @@ import { assertValidUnblockRequestEvent, assertValidUnblockRequestRecord, buildScoutReturnAddress, + namedChannelNaturalKey, + channelNaturalKeyFromMetadata, type ActorIdentity, type AgentDefinition, type AgentEndpoint, @@ -52,6 +55,8 @@ import { type ScoutDeliverResponse, type ScoutDeliverRouteKind, type ScoutProjectAgentSpec, + directChannelNaturalKey, + mintChannelId, type ThreadWatchCloseRequest, type ThreadWatchOpenRequest, type ThreadWatchRenewRequest, @@ -60,6 +65,7 @@ import { OPENSCOUT_IROH_MESH_ALPN, OPENSCOUT_MESH_PROTOCOL_VERSION, SCOUT_DISPATCHER_AGENT_ID, + systemChannelNaturalKey, normalizeAgentSelectorSegment, parseAgentIdentity, type AgentHarness, @@ -2141,6 +2147,9 @@ function brokerConversationChannel(snapshot: ReturnType return null; } + if (typeof conversation.metadata?.channel === "string") { + return conversation.metadata.channel; + } return conversation.id.startsWith("channel.") ? conversation.id.replace(/^channel\./, "") : null; @@ -2196,11 +2205,14 @@ function brokerTargetLabel(agent: AgentDefinition): string { return `@${handle && handle.length > 0 ? handle : agent.id}`; } -function brokerRouteKind(conversation: Pick): ScoutDeliverRouteKind { +function brokerRouteKind(conversation: Pick): ScoutDeliverRouteKind { if (conversation.kind === "direct") { return "dm"; } - return conversation.id === BROKER_SHARED_CHANNEL_ID ? "broadcast" : "channel"; + return conversation.id === BROKER_SHARED_CHANNEL_ID + || conversation.metadata?.channel === "shared" + ? "broadcast" + : "channel"; } function normalizeBrokerProductTarget(value: string): string { @@ -2295,6 +2307,20 @@ function directConversationIdForActors(sourceId: string, targetId: string): stri return `dm.${[sourceId, targetId].sort().join(".")}`; } +function findConversationByIdentity( + snapshot: ReturnType, + naturalKey: string, + legacyId?: string, +): ConversationDefinition | undefined { + if (legacyId && snapshot.conversations[legacyId]) { + return snapshot.conversations[legacyId]; + } + return Object.values(snapshot.conversations).find( + (conversation) => + channelNaturalKeyFromMetadata(conversation.metadata) === naturalKey, + ); +} + async function ensureBrokerActorForDelivery(actorId: string): Promise { const snapshot = runtime.snapshot(); if (snapshot.actors[actorId] || snapshot.agents[actorId]) { @@ -2320,12 +2346,16 @@ async function ensureBrokerDeliveryConversation(input: { const targetAgentId = input.targetAgentId?.trim(); if (!normalizedChannel && targetAgentId) { - const conversationId = targetAgentId === SCOUT_DISPATCHER_AGENT_ID && input.requesterId === operatorActorId + const legacyConversationId = targetAgentId === SCOUT_DISPATCHER_AGENT_ID && input.requesterId === operatorActorId ? BROKER_SHARED_CHANNEL_ID : directConversationIdForActors(input.requesterId, targetAgentId); const participantIds = [...new Set([input.requesterId, targetAgentId])].sort(); const shareMode = resolveConversationShareMode(snapshot, participantIds, "local"); - const existing = snapshot.conversations[conversationId]; + const naturalKey = directChannelNaturalKey(participantIds); + const existing = targetAgentId === SCOUT_DISPATCHER_AGENT_ID && input.requesterId === operatorActorId + ? snapshot.conversations[legacyConversationId] + : findConversationByIdentity(snapshot, naturalKey, legacyConversationId); + const conversationId = existing?.id ?? mintChannelId(randomUUID); const alreadyMatches = existing && existing.kind === "direct" && existing.visibility === "private" @@ -2349,6 +2379,8 @@ async function ensureBrokerDeliveryConversation(input: { participantIds, metadata: { surface: "broker", + naturalKey, + legacyId: legacyConversationId, ...(targetAgentId === SCOUT_DISPATCHER_AGENT_ID && input.requesterId === operatorActorId ? { role: "partner" } : {}), }, }; @@ -2366,48 +2398,77 @@ async function ensureBrokerDeliveryConversation(input: { let definition: ConversationDefinition; if (channel === "voice") { + const naturalKey = namedChannelNaturalKey("voice"); + const existing = findConversationByIdentity(snapshot, naturalKey, BROKER_VOICE_CHANNEL_ID); definition = { - id: BROKER_VOICE_CHANNEL_ID, + id: existing?.id ?? mintChannelId(randomUUID), kind: "channel", title: "voice", visibility: "workspace", shareMode: resolveConversationShareMode(snapshot, scopedParticipants, "local"), authorityNodeId: nodeId, participantIds: scopedParticipants, - metadata: { surface: "broker", channel: "voice" }, + metadata: { + surface: "broker", + channel: "voice", + naturalKey, + legacyId: BROKER_VOICE_CHANNEL_ID, + }, }; } else if (channel === "system") { + const naturalKey = systemChannelNaturalKey("system"); + const existing = findConversationByIdentity(snapshot, naturalKey, BROKER_SYSTEM_CHANNEL_ID); definition = { - id: BROKER_SYSTEM_CHANNEL_ID, + id: existing?.id ?? mintChannelId(randomUUID), kind: "system", title: "system", visibility: "system", shareMode: "local", authorityNodeId: nodeId, participantIds: [operatorActorId, input.requesterId].sort(), - metadata: { surface: "broker", channel: "system" }, + metadata: { + surface: "broker", + channel: "system", + naturalKey, + legacyId: BROKER_SYSTEM_CHANNEL_ID, + }, }; } else if (channel === "shared") { + const naturalKey = namedChannelNaturalKey("shared"); + const existing = findConversationByIdentity(snapshot, naturalKey, BROKER_SHARED_CHANNEL_ID); definition = { - id: BROKER_SHARED_CHANNEL_ID, + id: existing?.id ?? mintChannelId(randomUUID), kind: "channel", title: "shared-channel", visibility: "workspace", shareMode: "shared", authorityNodeId: nodeId, participantIds: sharedParticipants, - metadata: { surface: "broker", channel: "shared" }, + metadata: { + surface: "broker", + channel: "shared", + naturalKey, + legacyId: BROKER_SHARED_CHANNEL_ID, + }, }; } else { + const legacyChannelId = `channel.${sanitizeConversationSegment(channel)}`; + const naturalKey = namedChannelNaturalKey(channel); + const existing = findConversationByIdentity(snapshot, naturalKey, legacyChannelId); definition = { - id: `channel.${sanitizeConversationSegment(channel)}`, + id: existing?.id ?? mintChannelId(randomUUID), kind: "channel", title: channel, visibility: "workspace", shareMode: resolveConversationShareMode(snapshot, scopedParticipants, "local"), authorityNodeId: nodeId, participantIds: scopedParticipants, - metadata: { surface: "broker", channel }, + metadata: { + surface: "broker", + channel, + naturalKey, + legacyId: legacyChannelId, + }, }; } @@ -4424,6 +4485,14 @@ async function executeLocalInvocation( ...initialFlight, state: "queued" as const, summary: `Message stored for ${agent?.displayName ?? invocation.targetAgentId}. Will deliver when online.`, + metadata: { + ...(initialFlight.metadata ?? {}), + dispatchOutcome: { + status: "queued_until_online", + reason: "no_runnable_endpoint", + checkedAt: Date.now(), + }, + }, }; await persistFlight(queuedFlight); return; diff --git a/packages/runtime/src/conversations/api.test.ts b/packages/runtime/src/conversations/api.test.ts index c58e82eb..9a1deec5 100644 --- a/packages/runtime/src/conversations/api.test.ts +++ b/packages/runtime/src/conversations/api.test.ts @@ -3,7 +3,10 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import type { ConversationDefinition } from "@openscout/protocol"; +import { + directChannelNaturalKey, + type ConversationDefinition, +} from "@openscout/protocol"; import { SQLiteControlPlaneStore } from "../sqlite-store.ts"; import { conversationIdForAgent } from "./legacy-ids.ts"; @@ -106,7 +109,7 @@ describe("Conversations", () => { } }); - test("findByNaturalKey returns null in SCO-031 (column lands in SCO-030)", () => { + test("findByNaturalKey returns null when no metadata natural key exists", () => { const store = createStore(); try { seedActorsAndNode(store, ["operator", "agent-1"]); @@ -226,12 +229,13 @@ describe("Conversations", () => { } }); - test("ensureByNaturalKey uses the naturalKey as the row id (SCO-031 transitional)", () => { + test("ensureByNaturalKey mints an opaque id and stores the natural key", () => { const store = createStore(); try { seedActorsAndNode(store, ["operator", "agent-1"]); + const naturalKey = directChannelNaturalKey(["operator", "agent-1"]); const created = store.conversations.ensureByNaturalKey({ - naturalKey: "dm.operator.agent-1", + naturalKey, kind: "direct", title: "Direct", visibility: "private", @@ -239,11 +243,44 @@ describe("Conversations", () => { authorityNodeId: "node-1", participantIds: ["operator", "agent-1"], }); - expect(created.id).toBe("dm.operator.agent-1"); + expect(created.id).toMatch(/^c\.[0-9a-f-]{36}$/); + expect(created.metadata?.naturalKey).toBe(naturalKey); - const reloaded = store.conversations.findById("dm.operator.agent-1"); + const reloaded = store.conversations.findById(created.id); expect(reloaded?.kind).toBe("direct"); expect(reloaded?.participantIds.sort()).toEqual(["agent-1", "operator"]); + expect(store.conversations.findByNaturalKey(naturalKey)?.id).toBe(created.id); + + const duplicate = store.conversations.ensureByNaturalKey({ + naturalKey, + kind: "direct", + title: "Direct again", + visibility: "private", + shareMode: "local", + authorityNodeId: "node-1", + participantIds: ["operator", "agent-1"], + }); + expect(duplicate.id).toBe(created.id); + } finally { + store.close(); + } + }); + + test("findByAgent resolves opaque direct conversations by natural key", () => { + const store = createStore(); + try { + seedActorsAndNode(store, ["operator", "agent-1"]); + const created = store.conversations.ensureByNaturalKey({ + naturalKey: directChannelNaturalKey(["operator", "agent-1"]), + kind: "direct", + title: "Direct", + visibility: "private", + shareMode: "local", + authorityNodeId: "node-1", + participantIds: ["operator", "agent-1"], + }); + + expect(store.conversations.findByAgent("agent-1")?.id).toBe(created.id); } finally { store.close(); } @@ -263,6 +300,27 @@ describe("Conversations", () => { } }); + test("resolveLegacyId resolves structural direct ids to opaque natural-key rows", () => { + const store = createStore(); + try { + seedActorsAndNode(store, ["operator", "agent-1"]); + const created = store.conversations.ensureByNaturalKey({ + naturalKey: directChannelNaturalKey(["operator", "agent-1"]), + kind: "direct", + title: "Direct", + visibility: "private", + shareMode: "local", + authorityNodeId: "node-1", + participantIds: ["operator", "agent-1"], + }); + + const resolved = store.conversations.resolveLegacyId(conversationIdForAgent("agent-1")); + expect(resolved?.id).toBe(created.id); + } finally { + store.close(); + } + }); + test("resolveLegacyId returns null when no candidate row exists", () => { const store = createStore(); try { diff --git a/packages/runtime/src/conversations/api.ts b/packages/runtime/src/conversations/api.ts index 2e764b1f..0408238d 100644 --- a/packages/runtime/src/conversations/api.ts +++ b/packages/runtime/src/conversations/api.ts @@ -17,6 +17,8 @@ * `./legacy-ids.ts` for the structural form definitions. */ +import { randomUUID } from "node:crypto"; + import type { Database } from "bun:sqlite"; import type { @@ -27,6 +29,11 @@ import type { ShareMode, VisibilityScope, } from "@openscout/protocol"; +import { + channelNaturalKeyFromMetadata, + directChannelNaturalKey, + mintChannelId, +} from "@openscout/protocol"; import type { SQLiteControlPlaneStore } from "../sqlite-store.js"; @@ -81,12 +88,23 @@ export class Conversations implements ConversationsApi { return this.store.getConversation(id); } - /** - * SCO-030 (Opaque Conversation IDs) introduces the `natural_key` column and - * fills this body. In SCO-031 the method exists so call sites can be - * migrated without waiting on the schema change — it always returns `null`. - */ - findByNaturalKey(_key: string): ConversationDefinition | null { + findByNaturalKey(key: string): ConversationDefinition | null { + const normalizedKey = key.trim(); + if (!normalizedKey) { + return null; + } + const rows = this.readDb.query( + "SELECT id FROM conversations ORDER BY created_at ASC", + ).all() as ConversationRow[]; + for (const row of rows) { + const conversation = this.findById(row.id); + if ( + conversation && + channelNaturalKeyFromMetadata(conversation.metadata) === normalizedKey + ) { + return conversation; + } + } return null; } @@ -96,7 +114,8 @@ export class Conversations implements ConversationsApi { * pattern that used to live in `db-queries.ts`. */ findByAgent(agentId: ScoutId): ConversationDefinition | null { - return this.findById(conversationIdForAgent(agentId)); + return this.findById(conversationIdForAgent(agentId)) + ?? this.findByNaturalKey(directChannelNaturalKey(["operator", agentId])); } findByParent(parentId: ScoutId): ConversationDefinition[] { @@ -159,12 +178,6 @@ export class Conversations implements ConversationsApi { return winner ? this.findById(winner.id) : null; } - /** - * SCO-030 transitional bridge (per SCO-031 §13 Q5): in SCO-031 we use the - * `naturalKey` as the row's literal `id`. SCO-030 swaps this for an opaque - * id + `natural_key` lookup; the method shape stays the same so callers - * don't move twice. - */ ensureByNaturalKey(input: EnsureConversationInput): ConversationDefinition { const existing = this.findByNaturalKey(input.naturalKey); if (existing) { @@ -172,7 +185,7 @@ export class Conversations implements ConversationsApi { } const conversation: ConversationDefinition = { - id: input.naturalKey, + id: mintChannelId(randomUUID), kind: input.kind, title: input.title, visibility: input.visibility, @@ -181,7 +194,10 @@ export class Conversations implements ConversationsApi { participantIds: input.participantIds, parentConversationId: input.parentConversationId, topic: input.topic, - metadata: input.metadata, + metadata: { + ...(input.metadata ?? {}), + naturalKey: input.naturalKey, + }, }; this.upsert(conversation); return conversation; @@ -214,6 +230,12 @@ export class Conversations implements ConversationsApi { const parsedDirect = parseDirectConversationId(rawId); if (parsedDirect) { + const byNaturalKey = this.findByNaturalKey( + directChannelNaturalKey([parsedDirect.operatorId, parsedDirect.agentId]), + ); + if (byNaturalKey) { + return byNaturalKey; + } for (const candidate of directConversationIdCandidates(parsedDirect.agentId)) { const hit = this.findById(candidate); if (hit) { @@ -224,6 +246,12 @@ export class Conversations implements ConversationsApi { const legacyScoutAgentId = parseLegacyScoutSessionConversationId(rawId); if (legacyScoutAgentId) { + const byNaturalKey = this.findByNaturalKey( + directChannelNaturalKey(["operator", legacyScoutAgentId]), + ); + if (byNaturalKey) { + return byNaturalKey; + } for (const candidate of directConversationIdCandidates(legacyScoutAgentId)) { const hit = this.findById(candidate); if (hit) { diff --git a/packages/runtime/src/scout-broker.ts b/packages/runtime/src/scout-broker.ts index e4bdcbbe..0b29bc39 100644 --- a/packages/runtime/src/scout-broker.ts +++ b/packages/runtime/src/scout-broker.ts @@ -1,11 +1,17 @@ +import { randomUUID } from "node:crypto"; import { mkdir, readFile, unlink, writeFile } from "node:fs/promises"; import { basename, join, resolve } from "node:path"; import { BUILT_IN_AGENT_DEFINITION_IDS, + namedChannelNaturalKey, + channelNaturalKeyFromMetadata, diagnoseAgentIdentity, + directChannelNaturalKey, extractAgentSelectors, formatMinimalAgentIdentity, + mintChannelId, + systemChannelNaturalKey, normalizeAgentSelectorSegment, withAgentReferenceAliases, type AgentHarness, @@ -535,13 +541,28 @@ export function scoutConversationIdForChannel(channel?: string): string { return `channel.${sanitizeConversationSegment(normalizedChannel)}`; } +function resolveConversationIdForChannel( + snapshot: ScoutBrokerSnapshot, + channel?: string, +): string { + const normalizedChannel = channel?.trim() || "shared"; + const legacyId = scoutConversationIdForChannel(normalizedChannel); + const naturalKey = normalizedChannel === "system" + ? systemChannelNaturalKey("system") + : namedChannelNaturalKey(normalizedChannel); + return findConversationByIdentity(snapshot, naturalKey, legacyId)?.id ?? legacyId; +} + function relayRouteKind( - conversation: { id: string; kind: string }, + conversation: { id: string; kind: string; metadata?: Record }, ): "dm" | "channel" | "broadcast" { if (conversation.kind === "direct") { return "dm"; } - return conversation.id === BROKER_SHARED_CHANNEL_ID ? "broadcast" : "channel"; + return conversation.id === BROKER_SHARED_CHANNEL_ID + || conversation.metadata?.channel === "shared" + ? "broadcast" + : "channel"; } function buildMentionCandidate( @@ -1138,53 +1159,82 @@ function conversationDefinition( ]).sort(); if (normalizedChannel === "voice") { + const naturalKey = namedChannelNaturalKey("voice"); + const existing = findConversationByIdentity(snapshot, naturalKey, BROKER_VOICE_CHANNEL_ID); return { - id: BROKER_VOICE_CHANNEL_ID, + id: existing?.id ?? mintChannelId(randomUUID), kind: "channel", title: "voice", visibility: "workspace", shareMode: resolveConversationShareMode(snapshot, nodeId, scopedParticipants, "local"), authorityNodeId: nodeId, participantIds: scopedParticipants, - metadata: { surface: "scout-cli", channel: "voice" }, + metadata: { + surface: "scout-cli", + channel: "voice", + naturalKey, + legacyId: BROKER_VOICE_CHANNEL_ID, + }, }; } if (normalizedChannel === "system") { + const naturalKey = systemChannelNaturalKey("system"); + const existing = findConversationByIdentity(snapshot, naturalKey, BROKER_SYSTEM_CHANNEL_ID); return { - id: BROKER_SYSTEM_CHANNEL_ID, + id: existing?.id ?? mintChannelId(randomUUID), kind: "system", title: "system", visibility: "system", shareMode: "local", authorityNodeId: nodeId, participantIds: unique([OPERATOR_ID, senderId]).sort(), - metadata: { surface: "scout-cli", channel: "system" }, + metadata: { + surface: "scout-cli", + channel: "system", + naturalKey, + legacyId: BROKER_SYSTEM_CHANNEL_ID, + }, }; } if (normalizedChannel === "shared") { + const naturalKey = namedChannelNaturalKey("shared"); + const existing = findConversationByIdentity(snapshot, naturalKey, BROKER_SHARED_CHANNEL_ID); return { - id: BROKER_SHARED_CHANNEL_ID, + id: existing?.id ?? mintChannelId(randomUUID), kind: "channel", title: "shared-channel", visibility: "workspace", shareMode: "shared", authorityNodeId: nodeId, participantIds: sharedParticipants, - metadata: { surface: "scout-cli", channel: "shared" }, + metadata: { + surface: "scout-cli", + channel: "shared", + naturalKey, + legacyId: BROKER_SHARED_CHANNEL_ID, + }, }; } + const legacyChannelId = `channel.${sanitizeConversationSegment(normalizedChannel)}`; + const naturalKey = namedChannelNaturalKey(normalizedChannel); + const existing = findConversationByIdentity(snapshot, naturalKey, legacyChannelId); return { - id: `channel.${sanitizeConversationSegment(normalizedChannel)}`, + id: existing?.id ?? mintChannelId(randomUUID), kind: "channel", title: normalizedChannel, visibility: "workspace", shareMode: resolveConversationShareMode(snapshot, nodeId, scopedParticipants, "local"), authorityNodeId: nodeId, participantIds: scopedParticipants, - metadata: { surface: "scout-cli", channel: normalizedChannel }, + metadata: { + surface: "scout-cli", + channel: normalizedChannel, + naturalKey, + legacyId: legacyChannelId, + }, }; } @@ -1237,6 +1287,20 @@ function directConversationIdForActors(sourceId: string, targetId: string): stri return `dm.${[sourceId, targetId].sort().join(".")}`; } +function findConversationByIdentity( + snapshot: ScoutBrokerSnapshot, + naturalKey: string, + legacyId?: string, +): ScoutBrokerConversationRecord | undefined { + if (legacyId && snapshot.conversations[legacyId]) { + return snapshot.conversations[legacyId]; + } + return Object.values(snapshot.conversations).find( + (conversation) => + channelNaturalKeyFromMetadata(conversation.metadata) === naturalKey, + ); +} + async function ensureBrokerDirectConversationBetween( baseUrl: string, snapshot: ScoutBrokerSnapshot, @@ -1244,12 +1308,16 @@ async function ensureBrokerDirectConversationBetween( sourceId: string, targetId: string, ): Promise<{ agent: ScoutBrokerAgentRecord | undefined; conversation: ScoutBrokerConversationRecord; existed: boolean }> { - const conversationId = targetId === SCOUT_AGENT_ID && sourceId === OPERATOR_ID + const legacyConversationId = targetId === SCOUT_AGENT_ID && sourceId === OPERATOR_ID ? BROKER_SHARED_CHANNEL_ID : directConversationIdForActors(sourceId, targetId); const participantIds = [...new Set([sourceId, targetId])].sort(); + const naturalKey = directChannelNaturalKey(participantIds); + const existing = targetId === SCOUT_AGENT_ID && sourceId === OPERATOR_ID + ? snapshot.conversations[legacyConversationId] + : findConversationByIdentity(snapshot, naturalKey, legacyConversationId); + const conversationId = existing?.id ?? mintChannelId(randomUUID); const nextShareMode = resolveConversationShareMode(snapshot, nodeId, participantIds, "local"); - const existing = snapshot.conversations[conversationId]; const alreadyMatches = existing && existing.kind === "direct" && existing.shareMode === nextShareMode @@ -1280,6 +1348,8 @@ async function ensureBrokerDirectConversationBetween( participantIds, metadata: { surface: "scout", + naturalKey, + legacyId: legacyConversationId, ...(targetId === SCOUT_AGENT_ID && sourceId === OPERATOR_ID ? { role: "partner" } : {}), }, }; @@ -1611,8 +1681,12 @@ export async function loadScoutMessages(options: { limit?: number; baseUrl?: string; } = {}): Promise { + const baseUrl = options.baseUrl ?? resolveScoutBrokerUrl(); + const context = await loadScoutBrokerContext(baseUrl); const search = new URLSearchParams(); - const conversationId = scoutConversationIdForChannel(options.channel); + const conversationId = context + ? resolveConversationIdForChannel(context.snapshot, options.channel) + : scoutConversationIdForChannel(options.channel); if (conversationId) { search.set("conversationId", conversationId); } @@ -1623,12 +1697,12 @@ export async function loadScoutMessages(options: { search.set("limit", String(options.limit)); } const suffix = search.size > 0 ? `?${search.toString()}` : ""; - return brokerReadJson(options.baseUrl ?? resolveScoutBrokerUrl(), `/v1/messages${suffix}`); + return brokerReadJson(baseUrl, `/v1/messages${suffix}`); } export async function watchScoutMessages(options: ScoutWatchOptions): Promise { const broker = await requireScoutBrokerContext(); - const conversationId = scoutConversationIdForChannel(options.channel); + const conversationId = resolveConversationIdForChannel(broker.snapshot, options.channel); const controller = new AbortController(); const abort = () => controller.abort(); diff --git a/packages/runtime/src/sqlite-store.ts b/packages/runtime/src/sqlite-store.ts index a89aaf6e..d82092dc 100644 --- a/packages/runtime/src/sqlite-store.ts +++ b/packages/runtime/src/sqlite-store.ts @@ -2710,7 +2710,7 @@ export class SQLiteControlPlaneStore { return "ask_replied"; } - if (agentId && message.actorId !== agentId) { + if (agentId && message.actorId !== agentId && this.isDirectConversation(message.conversationId)) { return "ask_opened"; } @@ -2721,6 +2721,18 @@ export class SQLiteControlPlaneStore { return "message_posted"; } + private isDirectConversation(conversationId: string | undefined, db: Database = this.readDb): boolean { + if (!conversationId) { + return false; + } + const row = queryGet<{ kind: string }, [string]>( + db, + "SELECT kind FROM conversations WHERE id = ?1 LIMIT 1", + conversationId, + ); + return row?.kind === "direct"; + } + private resolveActivityAgentIdForMessage(message: MessageRecord): string | null { if (message.class === "status" && typeof message.metadata?.targetAgentId === "string" && this.isKnownAgentId(message.metadata.targetAgentId)) { return message.metadata.targetAgentId; diff --git a/packages/web/client/components/AgentDetailCard.tsx b/packages/web/client/components/AgentDetailCard.tsx index 7f85de2a..96939ef7 100644 --- a/packages/web/client/components/AgentDetailCard.tsx +++ b/packages/web/client/components/AgentDetailCard.tsx @@ -5,6 +5,7 @@ import type { Agent } from "../lib/types.ts"; import { normalizeAgentState } from "../lib/agent-state.ts"; import { stateColor } from "../lib/colors.ts"; import { timeAgo } from "../lib/time.ts"; +import { AgentLiveActions } from "./AgentLiveActions.tsx"; function homify(path: string | null | undefined): string | null { if (!path) return null; @@ -16,12 +17,13 @@ export type AgentDetailCardProps = { pinned: boolean; onOpen: () => void; onClose: () => void; + onAction?: () => void; style?: React.CSSProperties; className?: string; }; export const AgentDetailCard = forwardRef( - function AgentDetailCard({ agent, pinned, onOpen, onClose, style, className }, ref) { + function AgentDetailCard({ agent, pinned, onOpen, onClose, onAction, style, className }, ref) { const state = normalizeAgentState(agent.state); const cwd = homify(agent.cwd) ?? homify(agent.projectRoot); const name = agent.handle ?? agent.name; @@ -126,6 +128,12 @@ export const AgentDetailCard = forwardRef( )} + +