lib/openagents_web/live/home_live.ex

436d37facc96 · 27 KB

defmodule OpenAgentsWeb.HomeLive do
  @moduledoc """
  Two pages behind one route.

  Signed out, this is the landing page, composed from
  `OpenAgentsWeb.UI.Landing` so the homepage and the component library cannot
  drift apart.

  Signed in, a marketing pitch is the wrong thing to show: the visitor has
  already been sold, and what they want is the state of the work. The same
  route renders a dashboard -- repositories, open issues, projects, what the
  forum is discussing -- the way a code host's home does.

  The dashboard describes the viewer's work across every repository they can
  read, not one repository chosen for them, and it says so by reading through
  the same authorized reads `/issues` and `/projects` compose. Each panel
  carries its own counts, so no number on the page can be read as a total over
  a scope it does not cover.

  Everything on the dashboard is real and clickable. Where a panel has nothing
  to show it says so plainly rather than rendering a placeholder, because a
  dashboard whose emptiness is disguised is worse than one that admits it.

  It is also current. Every panel names a publisher and re-reads when it hears
  one, so an issue opened in another tab moves the count and the feed without a
  refresh, and the same holds for a project, a repository, and a forum post.
  The announcements carry ids and nothing else: each panel
  re-reads through the same authorized read that filled it at mount, so a
  viewer can never be handed a row -- or a row counted into a number -- the
  database would have refused them. Bursts collapse into one re-read of only
  the panels that moved.

  The landing page is flush -- it owns the full main area and scrolls itself --
  because landing bands set their own vertical rhythm against the viewport and
  a wrapper's padding would fight it. The dashboard is an ordinary page.
  """
  use OpenAgentsWeb, :live_view

  alias OpenAgents.Accounts
  alias OpenAgents.Forum
  alias OpenAgents.Issues
  alias OpenAgents.Projects
  alias OpenAgents.Repositories
  alias OpenAgentsWeb.LiveRefresh
  alias OpenAgentsWeb.UI.Landing

  # The one line the landing page asks a reader to run. It is the command
  # `priv/docs/install-cli.md` documents and the script
  # `priv/static/install.sh` serves, so the page cannot advertise an installer
  # this site does not hand out.
  @install_command "curl -fsSL https://openagents.com/install.sh | sh"

  @feed_limit 8
  @project_limit 6
  @post_limit 6

  @impl true
  def mount(params, _session, socket) do
    if socket.assigns[:current_user] do
      if connected?(socket), do: subscribe()

      {:ok,
       socket
       |> LiveRefresh.init()
       |> assign(:changed_repositories, MapSet.new())
       |> assign_dashboard()}
    else
      {:ok,
       socket
       |> assign(:device_user_code, device_user_code(params))
       |> assign(:install_command, @install_command)}
    end
  end

  # A reader who arrives here from `/device` was sent by their terminal, not by
  # a link to a product. `OpenAgentsWeb.UserAuth` puts the terminal's code in
  # the URL on the way past so this page can say what the sign-in is for; the
  # session it also wrote is what actually returns them afterwards, so this
  # value decides what is rendered and nothing else.
  #
  # It is cast rather than read. The parameter is printed on the page, so
  # admitting only the shape this application mints is what keeps a crafted
  # link from putting its own words in OpenAgents's mouth.
  defp device_user_code(params) when is_map(params) do
    case OpenAgents.DeviceAuthorizations.cast_user_code(params["user_code"]) do
      {:ok, code} -> code
      :error -> nil
    end
  end

  defp device_user_code(_not_mounted_at_router), do: nil

  # Every panel here already had a publisher or has one now, so the dashboard
  # can stop being a snapshot of the moment it was opened. The messages carry
  # ids and nothing else: each panel re-reads through the same authorized read
  # that filled it at mount, so a viewer can never be handed a row -- or a row
  # counted into a number -- that they could not have loaded themselves.
  defp subscribe do
    :ok = Repositories.subscribe_all_issues()
    :ok = Repositories.subscribe_all_projects()
    :ok = Repositories.subscribe_repository_changes()
    :ok = Forum.subscribe_posts()
  end

  @impl true
  def handle_info({:issues_changed, _repository_id}, socket),
    do: {:noreply, mark_stale(socket, :issues)}

  def handle_info({:projects_changed, _repository_id}, socket),
    do: {:noreply, mark_stale(socket, :projects)}

  def handle_info({:forum_posts_changed, _topic_id}, socket),
    do: {:noreply, mark_stale(socket, :posts)}

  def handle_info({:repository_changed, repository_id}, socket) do
    {:noreply,
     socket
     |> update(:changed_repositories, &MapSet.put(&1, repository_id))
     |> mark_stale(:repositories)}
  end

  def handle_info(:live_refresh, socket),
    do: {:noreply, LiveRefresh.run(socket, &refresh_panel/2)}

  def handle_info(_message, socket), do: {:noreply, socket}

  # One re-armed timer, shared with every other live surface through
  # `LiveRefresh`, so a burst of announcements costs one re-read rather than
  # one repaint per event. Which panels re-read is remembered beside it, so an
  # issue burst never reloads the forum, the projects, or the ledger.
  defp mark_stale(socket, panel), do: LiveRefresh.mark_stale(socket, panel, &refresh_panel/2)

  defp refresh_panel(socket, :issues), do: assign_issues(socket)
  defp refresh_panel(socket, :projects), do: assign_projects(socket)
  defp refresh_panel(socket, :posts), do: assign_posts(socket)

  defp refresh_panel(socket, :repositories) do
    socket.assigns.changed_repositories
    |> Enum.reduce(socket, &refresh_repository_row/2)
    |> assign(:changed_repositories, MapSet.new())
    |> assign(:any_repository?, Repositories.any_visible_repository?(socket.assigns.current_user))
  end

  # One row, re-read through this viewer's own visibility predicate, rather
  # than the whole collection: a repository announces itself on every accepted
  # push, and a rail that reloaded every repository it can read per push would
  # be the expensive half of the defect this fixes. A repository that has
  # stopped being readable -- deleted, or a membership revoked -- reads as
  # `nil` and leaves the rail.
  defp refresh_repository_row(repository_id, socket) do
    case Repositories.get_visible_repository(repository_id, socket.assigns.current_user) do
      nil -> stream_delete_by_dom_id(socket, :repositories, "repositories-#{repository_id}")
      repository -> stream_insert(socket, :repositories, repository)
    end
  end

  # Read once at mount and again whenever something says the panel moved.
  #
  # Read across every repository the viewer can read, through the same
  # authorized reads `/issues` and `/projects` compose, so the homepage cannot
  # disagree with them. It used to pick one repository -- the first `ready`
  # entry of the visible list -- and scope every panel and every count to it,
  # which reported `Open 0 / Closed 0 / Projects 0` to an owner whose backlog
  # lived in the repository that happened to sort second.
  defp assign_dashboard(socket) do
    socket
    |> assign_issues()
    |> assign_projects()
    |> assign_posts()
    |> assign_repositories()
  end

  # One bounded page, and the page carries its own unpaginated total, so the
  # numbers beside a panel cost an aggregate rather than a whole collection
  # loaded only to be measured. That is as true of a live update as of a mount:
  # a refresh that counted by loading would reintroduce the read `93c3383`
  # removed, once per event instead of once per visit.
  defp assign_issues(socket) do
    user = socket.assigns.current_user
    {issues, open_issue_count} = Issues.list_visible_issues_page(user, state: "open", page: 1)

    socket
    |> assign(:open_issue_count, open_issue_count)
    |> assign(:closed_issue_count, Issues.count_visible_issues(user, state: "closed"))
    |> assign(:issues, Enum.take(issues, @feed_limit))
  end

  defp assign_projects(socket) do
    user = socket.assigns.current_user

    {projects, open_project_count} =
      Projects.list_visible_projects_page(user, state: "open", page: 1)

    socket
    |> assign(:open_project_count, open_project_count)
    |> assign(:closed_project_count, Projects.count_visible_projects(user, state: "closed"))
    |> assign(:projects, Enum.take(projects, @project_limit))
  end

  # The forum reads through the same viewer-authorized scope `/forum` composes,
  # one row per topic, so the panel cannot name a board the board list would
  # not -- on a refresh no less than on a mount.
  defp assign_posts(socket) do
    assign(
      socket,
      :posts,
      Forum.list_recent_posts(
        operator?: Accounts.admin?(socket.assigns.current_user),
        limit: @post_limit
      )
    )
  end

  defp assign_repositories(socket) do
    repositories = Repositories.list_visible_repositories(socket.assigns.current_user)

    socket
    |> assign(:any_repository?, repositories != [])
    |> stream(:repositories, repositories)
  end

  @impl true
  def render(%{current_user: user} = assigns) when not is_nil(user) do
    ~H"""
    <Layouts.app
      flash={@flash}
      sidebar_sections={assigns[:sidebar_sections]}
      current_scope={@current_scope}
      title="Home"
      wide
    >
      <div class="dashboard">
        <div class="dashboard__main">
          <section class="panel" aria-labelledby="dashboard-issues">
            <header class="panel__header">
              <h2 id="dashboard-issues" class="panel__title">Open issues</h2>
              <%!-- Points at the workspace-wide list, not at one repository.
              A "view all" under a panel has to widen the set the panel shows,
              and a repository-scoped link under a cross-repository feed would
              narrow it instead. Per-repository entry points stay one row away
              in the Repositories panel. --%>
              <.link navigate={~p"/issues"} class="panel__more">
                View all <.icon name="arrow-right" />
              </.link>
            </header>

            <%!-- Beside the issues they count, rather than in the rail, where
            two integers with no panel attached read as a total over
            everything. --%>
            <dl class="stat-pairs" id="dashboard-issue-counts">
              <div>
                <dt>Open</dt>
                <dd id="dashboard-open-issue-count">{@open_issue_count}</dd>
              </div>
              <div>
                <dt>Closed</dt>
                <dd id="dashboard-closed-issue-count">{@closed_issue_count}</dd>
              </div>
            </dl>

            <ul :if={@issues != []} class="issue-feed">
              <li :for={issue <- @issues} class="issue-feed__row">
                <.icon name="circle-dashed" class="issue-feed__state" />
                <div class="issue-feed__body">
                  <.link
                    navigate={
                      ~p"/#{issue.repository.owner}/#{issue.repository.name}/issues/#{issue.number}"
                    }
                    class="issue-feed__title"
                  >
                    {issue.title}
                  </.link>
                  <p class="issue-feed__meta">
                    {issue.repository.owner}/{issue.repository.name} #{issue.number} opened {relative_time(
                      issue.inserted_at
                    )}
                  </p>
                </div>
                <span :if={issue.comments > 0} class="issue-feed__comments">
                  <.icon name="chat" /> {issue.comments}
                </span>
              </li>
            </ul>

            <p :if={@issues == [] and @any_repository?} class="panel__empty">
              No open issues in the repositories you can read.
            </p>
            <p :if={not @any_repository?} class="panel__empty">
              Import your first repository to start tracking issues.
            </p>
          </section>

          <section class="panel" aria-labelledby="dashboard-projects">
            <header class="panel__header">
              <h2 id="dashboard-projects" class="panel__title">Projects</h2>
              <.link navigate={~p"/projects"} class="panel__more">
                View all <.icon name="arrow-right" />
              </.link>
            </header>

            <dl class="stat-pairs" id="dashboard-project-counts">
              <div>
                <dt>Open</dt>
                <dd id="dashboard-open-project-count">{@open_project_count}</dd>
              </div>
              <div>
                <dt>Closed</dt>
                <dd id="dashboard-closed-project-count">{@closed_project_count}</dd>
              </div>
            </dl>

            <ul :if={@projects != []} class="project-list">
              <li :for={project <- @projects}>
                <.link
                  navigate={
                    ~p"/#{project.repository.owner}/#{project.repository.name}/projects/#{project.number}"
                  }
                  class="project-list__row"
                >
                  <.icon name="grid" />
                  <span class="project-list__title">{project.title}</span>
                  <span class="project-list__repository">
                    {project.repository.owner}/{project.repository.name}
                  </span>
                  <span class="project-list__state" data-state={project.state}>
                    {project.state}
                  </span>
                </.link>
              </li>
            </ul>

            <p :if={@projects == [] and @any_repository?} class="panel__empty">
              No open projects in the repositories you can read.
            </p>
            <p :if={not @any_repository?} class="panel__empty">
              Projects appear after you import a repository.
            </p>
          </section>
        </div>

        <aside class="dashboard__rail">
          <section class="panel" aria-labelledby="dashboard-repositories">
            <header class="panel__header">
              <h2 id="dashboard-repositories" class="panel__title">Repositories</h2>
              <.link navigate={~p"/repositories"} class="panel__more">
                View all <.icon name="arrow-right" />
              </.link>
            </header>

            <div id="dashboard-repository-list" phx-update="stream" class="space-y-2">
              <%!-- Carries an id because every child of a stream container
              needs one, including the one that is not a stream item. --%>
              <p id="dashboard-no-repositories" class="panel__empty hidden only:block">
                No repositories yet.
              </p>
              <.link
                :for={{id, repository} <- @streams.repositories}
                id={id}
                navigate={~p"/#{repository.namespace.slug}/#{repository.name}"}
                class="repo-card"
              >
                <.icon name="folder" />
                <span class="repo-card__name">
                  {repository.namespace.slug}/{repository.name}
                </span>
                <.badge variant={if(repository.lifecycle_state == "ready", do: :success, else: :info)}>
                  {repository.lifecycle_state}
                </.badge>
              </.link>
            </div>
          </section>

          <%!-- The one surface here where other people speak. It reads
          through the forum's own authorized read, shows the newest post of
          each recently active topic rather than several from one thread, and
          points at `/forum` -- the destination that widens the set -- rather
          than at whichever board happens to be busiest. --%>
          <section class="panel" aria-labelledby="dashboard-forum">
            <header class="panel__header">
              <h2 id="dashboard-forum" class="panel__title">Recent posts</h2>
              <.link navigate={~p"/forum"} class="panel__more">
                View all <.icon name="arrow-right" />
              </.link>
            </header>

            <ul :if={@posts != []} id="dashboard-post-list" class="post-rail">
              <li :for={post <- @posts} id={"dashboard-post-#{post.id}"} class="post-rail__row">
                <.link navigate={~p"/forum/t/#{post.topic_id}"} class="post-rail__title">
                  {post.topic.title}
                </.link>
                <%!-- The name the post was written under, which is what the
                thread shows. A migrated post keeps its legacy name until an
                identity claim resolves it, and the dashboard must not
                attribute it to an account the forum would not. --%>
                <p class="post-rail__meta">
                  {post.actor_display_name} in {post.topic.forum.title} · {relative_time(
                    post.created_at
                  )}
                </p>
              </li>
            </ul>

            <p :if={@posts == []} class="panel__empty">
              No posts yet on the boards you can read.
            </p>
          </section>
        </aside>
      </div>
    </Layouts.app>
    """
  end

  # A reader mid-way through authorizing a terminal is not here to be sold the
  # product; they are here because a sign-in stands between them and one
  # approval. Showing them the landing page would be the third screen in a row
  # that does not mention what they are actually doing, so this says it: the
  # terminal, its code, and where the sign-in puts them next.
  #
  # None of the sign-in controls need the code threaded through them. It is in
  # the session already, so the command bar's control returns the reader to the
  # approval exactly as this one does.
  def render(%{device_user_code: code} = assigns) when is_binary(code) do
    ~H"""
    <Layouts.app
      flash={@flash}
      sidebar_sections={assigns[:sidebar_sections]}
      current_scope={@current_scope}
    >
      <main id="device-sign-in" class="mx-auto w-full max-w-lg space-y-8 px-4 py-16">
        <.header>
          Sign in to authorize your terminal
          <:subtitle>
            Your terminal is waiting on this code. Signing in brings you straight back to
            the approval — you will not need to enter it again.
          </:subtitle>
        </.header>

        <.card>
          <div class="space-y-6">
            <div>
              <p class="text-sm text-muted-foreground">Code shown in your terminal</p>
              <code id="device-sign-in-code" class="text-2xl font-semibold tracking-widest">
                {@device_user_code}
              </code>
            </div>
            <p class="text-sm text-muted-foreground">
              Check that it matches before you approve. OpenAgents signs you in through GitHub;
              your GitHub token stays here and never reaches the terminal.
            </p>
            <.github_login id="device-signin" size={:lg} />
          </div>
        </.card>
      </main>
    </Layouts.app>
    """
  end

  def render(assigns) do
    ~H"""
    <Layouts.app
      flash={@flash}
      sidebar_sections={assigns[:sidebar_sections]}
      current_scope={@current_scope}
      flush
    >
      <div class="landing-page">
        <Landing.layout_lines />

        <%!-- The band leads with the product a reader can have in one command,
        not with the platform behind it. The install line is the only action
        here on purpose: signing in is what the command bar is for, and a hero
        that offers two ways to start makes the reader choose before they know
        enough to choose well.

        It closes with a rule rather than running straight into the feature
        grid, so the claim and the way to act on it read as one band. --%>
        <Landing.hero
          title="Coder."
          title_lead="Introducing"
          description="Your all-in-one coding agent."
          rule
        >
          <%!-- Commented out rather than deleted, and for the same reason as
          the figure below: the shape is right and what it announces does not
          exist yet. A pill is a link, and one that opens a post nobody has
          written spends the reader's attention and returns none of it.
          Restore it when there is a post to point at.

          <:eyebrow>
            <Landing.announce
              lead="Coder is here"
              detail="Install it in one command"
              navigate={~p"/coder"}
            />
          </:eyebrow>
          --%>

          <:command>
            <Landing.install_command id="home-install-command" command={@install_command} />
          </:command>

          <%!-- Two lines, not one sentence. The first is what the reader gets
          and the second is what it costs them to get it, and running them
          together would let the second read as a condition on the credit
          rather than on the account. --%>
          <:note>
            Every new account starts with $20 of credit.<br />Login with GitHub required.
          </:note>

          <:links>
            <.button navigate={~p"/docs"} variant={:ghost} size={:sm} class="hero__link">
              Read the docs <.icon name="chevron-right" />
            </.button>
            <.button
              navigate={~p"/OpenAgentsInc/openagents.com"}
              variant={:ghost}
              size={:sm}
              class="hero__link"
            >
              Open source <.icon name="chevron-right" />
            </.button>
          </:links>

          <%!-- Commented out rather than deleted: the frame is right, what goes
          in it is not. An empty browser chrome captioned with the domain shows
          a reader nothing about the product and reads as a placeholder that
          was forgotten. Restore this when there is a real surface to put in
          it -- a screenshot, or a live view of the application.

          <:figure>
            <Landing.mockup>
              <div class="landing-figure">
                <div class="landing-figure__bar">
                  <span></span><span></span><span></span>
                </div>
                <p class="landing-figure__caption">openagents.com</p>
              </div>
            </Landing.mockup>
          </:figure>
          --%>
        </Landing.hero>

        <Landing.feature_grid title="One agent, wired to the forge it ships from.">
          <:item title="A session, or a command" icon="terminal">
            Run it bare and it opens a coding session. Give it a command and it runs
            that instead. Three names, one binary.
          </:item>
          <:item title="Where your code already is" icon="folder">
            It reads files and runs shell commands in the checkout you are standing in,
            on your machine, under your account.
          </:item>
          <:item title="Issues and projects" icon="file-document">
            Read and write issues, labels, milestones, assignees, and projects from the
            session, over the GitHub-shaped API this forge serves.
          </:item>
          <:item title="Push, already authenticated" icon="branch">
            One command installs a Git credential helper scoped to this origin. After
            it, plain <code>git push</code> works, and every accepted push leaves a
            receipt.
          </:item>
          <:item title="Sessions that survive" icon="memory-on-remember">
            The transcript is written to the server as the session runs, so resuming
            picks up where you stopped — on this machine or another one.
          </:item>
          <:item title="Plugins in a sandbox" icon="plugin">
            Extra tools ship as WebAssembly, mounted read-only and pinned to a digest.
            Ask for one and the session loads it.
          </:item>
          <:item title="Delegation" icon="robot">
            Hand part of the work to a child agent, on a rented sandbox or on a machine
            you paired yourself, and keep going.
          </:item>
          <:item title="Credit, not a bill" icon="credits">
            Every call meters against one balance, and the session can tell you what is
            left. No plan to pick, and no ceiling on how long a session runs.
          </:item>
        </Landing.feature_grid>

        <Landing.faq title="Questions">
          <:item question="What does that install command do?" open>
            <p>
              It detects your operating system and processor, downloads the matching
              build, fetches the checksum file separately, and refuses anything it
              cannot verify — the bytes are never made executable before the
              comparison succeeds. Then it links three names, <code>openagents</code>, <code>coder</code>, and <code>oa</code>, into <code>~/.openagents/bin</code>. macOS builds are signed and notarized, so
              Gatekeeper admits them without a right-click override.
            </p>
          </:item>
          <:item question="Do I need an account?">
            <p>
              Yes, and identity comes from GitHub — there is no separate
              OpenAgents password. Signing in from the terminal runs the device flow: it prints a
              code and a URL, you approve it in a browser, and the token lands in your
              operating system's credential store. Your GitHub token stays here and
              never reaches the terminal.
            </p>
          </:item>
          <:item question="Which model answers?">
            <p>
              One you picked, for the whole session. The
              <.link navigate={~p"/models"}>models page</.link>
              lists every model this deployment serves with its rates and context
              window, and no call is ever answered by a different model than the one
              you asked for.
            </p>
          </:item>
          <:item question="Is this the whole application?">
            <p>
              Yes. The repository is AGPL-3.0, the forge Coder talks to is this site,
              and every surface on it is built from the same component system
              documented in the component library.
            </p>
          </:item>
        </Landing.faq>

        <Landing.cta
          title="Put it in your terminal."
          description="One command, and the agent is where the work is."
        >
          <:actions>
            <.button navigate={~p"/docs/install-cli"} variant={:primary} size={:lg}>
              Install Coder
            </.button>
          </:actions>
        </Landing.cta>

        <Landing.landing_footer
          tagline="Your all-in-one coding agent, wired to the forge it ships from."
          copyright="© 2026 OpenAgents, Inc."
          note="AGPL-3.0. Every surface here is in the repository."
        >
          <:column title="Coder">
            <.link navigate={~p"/docs/install-cli"}>Install</.link>
            <.link navigate={~p"/docs"}>Documentation</.link>
            <.link navigate={~p"/models"}>Models</.link>
          </:column>
          <:column title="Transparency">
            <.link navigate={~p"/status"}>Status</.link>
            <.link navigate={~p"/leaderboard"}>Leaderboard</.link>
          </:column>
          <:column title="API">
            <.link navigate={~p"/docs/rest-api"}>REST API</.link>
            <.link navigate={~p"/docs/status-api"}>Status API</.link>
          </:column>
        </Landing.landing_footer>
      </div>
    </Layouts.app>
    """
  end

  # Coarse on purpose. A dashboard wants "roughly when"; a precise timestamp
  # there invites the reader to do arithmetic they did not want to do.
  defp relative_time(nil), do: "recently"

  defp relative_time(%DateTime{} = at) do
    case DateTime.diff(DateTime.utc_now(), at, :second) do
      s when s < 60 -> "just now"
      s when s < 3_600 -> "#{div(s, 60)}m ago"
      s when s < 86_400 -> "#{div(s, 3_600)}h ago"
      s when s < 2_592_000 -> "#{div(s, 86_400)}d ago"
      s -> "#{div(s, 2_592_000)}mo ago"
    end
  end

  defp relative_time(%NaiveDateTime{} = at),
    do: at |> DateTime.from_naive!("Etc/UTC") |> relative_time()
end