defmodule OpenAgentsWeb.Layouts do
@moduledoc """
This module holds layouts and related functionality
used by your application.
"""
use OpenAgentsWeb, :html
alias OpenAgentsWeb.UI, as: UI
# Embed all files in layouts/* within this module.
# The default root.html.heex file contains the HTML
# skeleton of your application, namely HTML headers
# and other static content.
embed_templates "layouts/*"
@doc """
Renders your app layout.
This function is typically invoked from every template,
and it often contains your application menu, sidebar,
or similar.
## Examples
<Layouts.app flash={@flash} sidebar_sections={assigns[:sidebar_sections]}>
<h1>Content</h1>
</Layouts.app>
"""
attr :flash, :map, required: true, doc: "the map of flash messages"
attr :current_scope, :map,
default: nil,
doc: "the current [scope](https://phoenix.hexdocs.pm/scopes.html)"
attr :sidebar_sections, :any,
default: %{},
doc: """
Which sidebar sections the reader has collapsed, from their cookie. Passed
down rather than read ambiently so a page that renders a sidebar cannot
quietly lose it -- `test/openagents_web/sidebar_state_test.exs` fails on a
call site that omits it.
"""
attr :wide, :boolean,
default: false,
doc: "use a wider content column for catalog and list surfaces"
attr :full_width, :boolean,
default: false,
doc: "let a signed-out application surface use the full content area"
attr :title, :string, default: nil, doc: "the page title to display in the command bar"
attr :subtitle, :string, default: nil, doc: "the page subtitle to display in the command bar"
attr :flush, :boolean,
default: false,
doc: "surface owns the full main area: no padding, no scroll of its own"
slot :inner_block, required: true
slot :title_menu,
doc: """
Actions belonging to this page, rendered beside its name in the command
bar. Nested, never passed as an attribute: forwarding a slot as an attr
compiles and renders but loses change tracking.
"""
slot :sidebar_extra,
doc: """
Rows this page contributes to the application sidebar.
A page with its own destinations adds them to the one sidebar rather than
rendering a second one inside the content area. Chat did the latter, which
put a whole nested application shell -- brand, rail, account footer --
inside the padded main of the shell that already had one.
"""
def app(assigns) do
~H"""
<%!-- Sized in dynamic viewport units, never `100vh`. On a mobile browser
`100vh` is the viewport with the chrome collapsed, so a shell measured
against it is taller than what the reader can see: the tail of the page
sits under the toolbar, and because the document itself does not scroll
there is no gesture that would collapse the chrome to reveal it. `100dvh`
tracks the visible viewport as the chrome comes and goes, so `<main>`
scrolls to its final element with the toolbar present. On a desktop
viewport the two units are the same value. --%>
<div
id="app-shell"
class="h-dvh flex overflow-hidden bg-background"
phx-hook=".AppSidebar"
>
<.sidebar
:if={@current_scope}
current_scope={@current_scope}
sidebar_sections={@sidebar_sections || %{}}
>
<:extra>{render_slot(@sidebar_extra)}</:extra>
</.sidebar>
<button
:if={@current_scope}
id="sidebar-scrim"
type="button"
class="sidebar-scrim"
aria-label="Close navigation sidebar"
aria-hidden="true"
tabindex="-1"
></button>
<div class="flex-1 min-w-0 flex flex-col h-full">
<.openagents_command_bar current_scope={@current_scope} title={@title} subtitle={@subtitle}>
<:menu>{render_slot(@title_menu)}</:menu>
</.openagents_command_bar>
<main class={[
"flex-1 min-w-0",
@flush && "flex min-h-0 flex-col overflow-hidden",
!@flush && "overflow-y-auto overscroll-none p-4",
@current_scope && "bg-background"
]}>
<%!-- A flush surface owns the whole area and sets its own measure,
so it is not wrapped either way. Signed out, an ordinary page is
centred in a reading column; signed in, the page has the sidebar
beside it and sets its own. --%>
<%= if @flush or @current_scope do %>
{render_slot(@inner_block)}
<% else %>
<div class={[
"mx-auto w-full space-y-4",
@full_width && "max-w-none",
!@full_width && @wide && "max-w-6xl",
!@full_width && !@wide && "max-w-2xl"
]}>
{render_slot(@inner_block)}
</div>
<% end %>
</main>
</div>
<.flash_group flash={@flash} class="fixed bottom-4 right-4 z-50" />
<script :type={Phoenix.LiveView.ColocatedHook} name=".AppSidebar">
export default {
mounted() {
this.desktop = window.matchMedia("(min-width: 1024px)")
this.onClick = event => this.handleClick(event)
this.onKeydown = event => this.handleKeydown(event)
this.onBreakpointChange = () => this.restoreForViewport()
this.el.addEventListener("click", this.onClick)
window.addEventListener("keydown", this.onKeydown)
this.desktop.addEventListener("change", this.onBreakpointChange)
this.restoreForViewport()
},
updated() {
this.applyState(this.open)
},
destroyed() {
this.el.removeEventListener("click", this.onClick)
window.removeEventListener("keydown", this.onKeydown)
this.desktop.removeEventListener("change", this.onBreakpointChange)
document.body.classList.remove("sidebar-open")
},
handleClick(event) {
if (event.target.closest("[data-sidebar-toggle]")) {
const opening = !this.open
this.applyState(opening, opening ? {focusSidebar: true} : {restoreFocus: true})
return
}
if (event.target.closest("#sidebar-scrim")) {
this.applyState(false, {restoreFocus: true})
return
}
if (!this.desktop.matches && event.target.closest("#sidebar a")) {
this.applyState(false)
}
},
handleKeydown(event) {
if (event.key === "Escape" && this.open && !this.desktop.matches) {
this.applyState(false, {restoreFocus: true})
}
},
restoreForViewport() {
const open = this.desktop.matches
this.applyState(open)
},
applyState(open, options = {}) {
const sidebar = this.el.querySelector("#sidebar")
const toggles = this.el.querySelectorAll("[data-sidebar-toggle]")
const expandToggle = this.el.querySelector("#sidebar-expand-toggle")
const scrim = this.el.querySelector("#sidebar-scrim")
if (!sidebar || toggles.length === 0 || !expandToggle || !scrim) return
this.open = open
this.el.dataset.sidebarInitialized = "true"
this.el.dataset.sidebarOpen = open ? "true" : "false"
sidebar.setAttribute("aria-hidden", open ? "false" : "true")
sidebar.inert = !open
toggles.forEach(toggle => toggle.setAttribute("aria-expanded", open ? "true" : "false"))
scrim.setAttribute("aria-hidden", open ? "false" : "true")
document.body.classList.toggle("sidebar-open", open && !this.desktop.matches)
if (options.focusSidebar && !this.desktop.matches) {
window.requestAnimationFrame(() => {
sidebar.querySelector("a, button, summary")?.focus()
})
} else if (options.restoreFocus) {
expandToggle.focus()
}
}
}
</script>
</div>
"""
end
attr :current_scope, :map, default: nil
attr :title, :string, default: nil
attr :subtitle, :string, default: nil
slot :menu, doc: "actions belonging to the current page"
defp openagents_command_bar(assigns) do
~H"""
<header class="flex items-center gap-2 bg-background border-b border-border px-4 h-[52px] shrink-0">
<div class="flex flex-1 min-w-0 items-center gap-2">
<UI.button
:if={@current_scope}
id="sidebar-expand-toggle"
class="sidebar-toggle sidebar-toggle--expand"
variant={:ghost}
size={:sm}
data-sidebar-toggle
aria-label="Open navigation sidebar"
aria-controls="sidebar"
aria-expanded="false"
title="Open navigation sidebar"
>
<UI.icon name="sidebar-open-left" />
</UI.button>
<%= if !@current_scope do %>
<.link navigate={~p"/"} class="btn text-xl text-foreground" data-variant="ghost">
OpenAgents
</.link>
<% end %>
<%= if @title do %>
<div class="flex flex-col justify-center min-w-0">
<h1 class="text-base font-semibold leading-tight truncate">{@title}</h1>
<%= if @subtitle do %>
<p class="text-xs text-muted-foreground truncate">{@subtitle}</p>
<% end %>
</div>
{render_slot(@menu)}
<% end %>
</div>
<div class="flex items-center gap-2">
<.theme_toggle />
<%= if @current_scope do %>
<.account_dropdown current_scope={@current_scope} />
<% else %>
<%!-- A real sign-in, not a link to the homepage's anchor: a control
labelled "log in" that navigates somewhere else instead is lying
about what it does. --%>
<UI.github_login id="command-bar-signin" size={:sm} />
<% end %>
</div>
</header>
"""
end
@doc """
The OpenAgents command bar: brand lockup on the left, account controls on the
right. `current_user` is optional because public surfaces are anonymous.
"""
attr :aria_label, :string, required: true
attr :current_user, :map, default: nil
slot :lockup, doc: "chip controls rendered beside the brand name"
slot :controls, doc: "surface-specific controls rendered before the account menu"
def command_bar(assigns) do
~H"""
<header class="command-bar" aria-label={@aria_label}>
<div class="brand-lockup">
<span class="brand-name">OpenAgents</span>
{render_slot(@lockup)}
</div>
<div class="command-controls">
{render_slot(@controls)}
<.theme_toggle />
<.account_control :if={@current_user} current_user={@current_user} />
</div>
</header>
"""
end
@doc """
The brand lockup at the top of a documentation sidebar.
Two targets, because they answer two different questions. The mark returns to
the application, for a reader who arrived from a search result and wants the
product. The title returns to this section's own index, for a reader who is
already in the docs and wants the contents.
Collapsing them into one link would cost one of those, and giving the index
its own sidebar row would list a destination the reader is already looking at.
"""
attr :title, :string, default: nil, doc: "section name; omitted in the app shell"
attr :path, :string, default: nil, doc: "this section's index"
def sidebar_brand(assigns) do
~H"""
<header class="sidebar-brand">
<%!-- The application's own rail carries the wordmark; the docs and
component shells do not, because the section name beside the divider is
already doing that half of the job and "OpenAgents | Docs" said the brand
twice. Mark and word are one link, so the whole lockup goes home. --%>
<.link navigate={~p"/"} class="sidebar-brand__mark" aria-label="OpenAgents home">
<img src={~p"/favicon-32x32.png"} alt="" width="24" height="24" />
<span :if={is_nil(@title)} class="sidebar-brand__wordmark">OpenAgents</span>
</.link>
<span :if={@title} class="sidebar-brand__divider" aria-hidden="true"></span>
<.link :if={@title} patch={@path} class="sidebar-brand__title">{@title}</.link>
</header>
"""
end
@doc """
The secondary links at the foot of a sidebar.
Every sidebar carries the same set, so a reader who finds the component
library from the application can get back to the docs from either, and does
not have to remember which shell they are in.
The component library is advertised outside production only. It documents
the parts a page is built from rather than anything a visitor came for.
"""
# Required, with no default. Defaulting to nil made a forgotten attribute
# look exactly like a signed-out visitor, and the docs and component-library
# layouts both forgot it -- so an operator browsing either surface silently
# lost the Admin row. `nil` is still a fine thing to pass; it just has to be
# said.
attr :current_user, :any,
required: true,
doc: "the scope whose access decides the admin row; nil for a visitor"
def sidebar_footer(assigns) do
assigns =
assigns
|> assign(:components_link?, OpenAgents.RuntimeConfig.internal_surfaces_visible?())
|> assign(:admin_link?, admin?(assigns[:current_user]))
~H"""
<footer class="sidebar-footer">
<.link navigate={~p"/docs"} class="sidebar-footer__link">
<UI.icon name="book" /> Documentation
</.link>
<.link :if={@components_link?} navigate={~p"/components"} class="sidebar-footer__link">
<UI.icon name="widget" /> Components
</.link>
<.link navigate={~p"/leaderboard"} class="sidebar-footer__link">
<UI.icon name="trophy-top" /> Leaderboard
</.link>
<%!-- Directly under Leaderboard, and shown to everyone. The forum reads
are public, so a visitor following this row reaches the boards rather
than a login wall. No DOM id: the component gallery renders this footer
beside the shell's own, and a fixed id would be duplicated on that page
now that the row no longer depends on a session. --%>
<.link navigate={~p"/forum"} class="sidebar-footer__link">
<UI.icon name="forum" /> Forum
</.link>
<.link
:if={@admin_link?}
id="open-admin"
navigate={~p"/admin"}
class="sidebar-footer__link"
aria-label="Admin"
>
<UI.icon name="shield-lock" /> Admin
</.link>
</footer>
"""
end
@doc """
A collapsible sidebar section.
A native `<details>`, so it needs no JavaScript, is keyboard operable, and
reports its own state to assistive technology. Because the sidebar now
patches rather than remounts, the element survives navigation and so does
whatever the reader collapsed.
`open` should be true for the section holding the current page, so a reader
who has never touched this section lands with their own location visible.
It is a seed: once the reader collapses a section, `state` carries that
choice and it wins, even for the section they are reading.
"""
attr :title, :string, required: true
attr :open, :boolean,
default: false,
doc: "the seed, used until the reader has said otherwise for this section"
attr :state, :map,
default: %{},
doc: """
The reader's collapsed/expanded sections, keyed by element id, as carried
from their cookie by `OpenAgentsWeb.Plugs.SidebarSections`. Applied here,
on the server, so a full page load paints what they chose rather than
painting the seed and having a hook correct it a frame later.
"""
attr :id, :string,
default: nil,
doc: """
Defaults to a slug of the title, which is also the key the reader's choice
is stored under. Section ids are one namespace across every surface, so an
identically titled section shares its collapsed state; pass an explicit id
for a section that must stand alone. See
`OpenAgentsWeb.Plugs.SidebarSections`.
"""
slot :inner_block, required: true
def sidebar_section(assigns) do
# Not `assign_new`: the `id` attr declaration already puts the key in
# assigns as nil, so `assign_new` would consider it present and never
# compute. A hook without a DOM id silently does not run.
assigns =
assigns
|> assign(:id, assigns.id || "sidebar-section-" <> section_slug(assigns.title))
|> then(fn assigns ->
assign(assigns, :open, Map.get(assigns.state, assigns.id, assigns.open))
end)
~H"""
<details
id={@id}
class="docs-sidebar__section sidebar-section"
open={@open}
phx-hook=".SidebarSection"
>
<summary class="sidebar-section-label sidebar-section__summary">
<UI.icon name="chevron-right" class="sidebar-section__caret" />
<span>{@title}</span>
</summary>
<div class="sidebar-section__items">
{render_slot(@inner_block)}
</div>
</details>
<%!-- `open` above is a seed, not the truth. The server already resolves
the reader's choice against it, so the seed decides the first paint only
for a section the reader has never spoken about. The hook records each
turn of a caret in the cookie and re-applies the reader's choice after a
LiveView update, whose diff would otherwise repaint the seed. With no
JavaScript the seed governs and the sidebar still works. --%>
<script :type={Phoenix.LiveView.ColocatedHook} name=".SidebarSection">
// A cookie rather than sessionStorage, because the server has to know.
// Several sidebar destinations live in different live sessions, so
// moving between them is a full page load; state the server cannot read
// arrives too late, and the reader watches a section they collapsed
// paint open and then shut. See `OpenAgentsWeb.Plugs.SidebarSections`.
const KEY = "sidebar_sections"
const read = () => {
const entry = document.cookie
.split("; ")
.find((part) => part.startsWith(KEY + "="))
if (!entry) return {}
try {
return JSON.parse(decodeURIComponent(entry.slice(KEY.length + 1))) || {}
} catch (_error) {
return {}
}
}
const write = (state) => {
const value = encodeURIComponent(JSON.stringify(state))
// Session-scoped, same-site, and readable by script because script is
// what maintains it. It holds which sidebar sections are open, so it
// is not worth protecting and must not be sent cross-site.
document.cookie = KEY + "=" + value + "; path=/; samesite=lax"
}
export default {
mounted() {
this.onToggle = () => {
const state = read()
state[this.el.id] = this.el.open
write(state)
}
// The caret rotates only for a turn the reader made. Navigation
// re-renders `open`, so an always-on transition played an animation
// on every move between pages. The attribute lives for one click.
this.onClick = () => {
this.el.dataset.animate = "true"
clearTimeout(this.animateTimer)
this.animateTimer = setTimeout(() => delete this.el.dataset.animate, 200)
}
this.summary = this.el.querySelector("summary")
this.el.addEventListener("toggle", this.onToggle)
if (this.summary) this.summary.addEventListener("click", this.onClick)
this.restore()
},
updated() {
this.restore()
},
destroyed() {
clearTimeout(this.animateTimer)
this.el.removeEventListener("toggle", this.onToggle)
if (this.summary) this.summary.removeEventListener("click", this.onClick)
},
restore() {
// Only the reader's own choice is applied, and it is never written
// back from here. Forcing the section that holds the active page
// open instead -- and recording that as a choice -- is what made
// /docs and /components forget a collapse the application shell
// kept: every row on those surfaces marks itself `aria-current`, so
// every reload re-opened the section being read and overwrote the
// cookie with `true`. A section holding the current page still
// opens for a reader who has not collapsed it, through the
// server-rendered seed.
const stored = read()[this.el.id]
if (stored !== undefined) this.el.open = stored
},
}
</script>
"""
end
# Ids have to survive navigation for the reader's open sections to be found
# again, so they come from the section name rather than a render-time counter.
defp section_slug(title) do
title
|> String.downcase()
|> String.replace(~r/[^a-z0-9]+/u, "-")
|> String.trim("-")
end
@doc """
A sidebar row that navigates without throwing the sidebar away.
`/components` and `/components/:slug` are the same LiveView, so moving
between them is a patch: the DOM is diffed, the row's selected state updates
in place, and the sidebar keeps its scroll position. `navigate` would remount
and scroll the list back to the top on every click.
`/components/icons` is a different LiveView, so a patch cannot reach it and a
patch cannot leave it. `patchable` says whether the currently mounted view is
the one that owns these params; when it is not, the row falls back to a
navigate that remounts on purpose.
"""
attr :path, :string, required: true
attr :label, :string, required: true
attr :icon, :string, required: true
attr :selected, :boolean, default: false
attr :patchable, :boolean, default: false
attr :badge, :integer,
default: nil,
doc: """
A count to show at the end of the row. Omitted entirely at zero and at nil:
a badge reading `0` is a claim on attention that turns out to be about
nothing, and a row that always carries one stops meaning anything when it
changes.
"""
def sidebar_link(assigns) do
~H"""
<div class="sidebar-row" data-selected={@selected}>
<.link
:if={@patchable}
patch={@path}
class="sidebar-row__hit"
aria-label={@label}
aria-current={@selected && "page"}
></.link>
<.link
:if={!@patchable}
navigate={@path}
class="sidebar-row__hit"
aria-label={@label}
aria-current={@selected && "page"}
></.link>
<span class="sidebar-row__content">
<span class="sidebar-row__icon"><UI.icon name={@icon} /></span>
<span class="sidebar-row__label">{@label}</span>
</span>
<UI.badge
:if={is_integer(@badge) and @badge > 0}
id={"sidebar-badge-#{section_slug(@label)}"}
variant={:info}
class="sidebar-row__badge"
aria-label={"#{@badge} unread"}
>
{@badge}
</UI.badge>
</div>
"""
end
@doc """
One control that flips between light and dark.
There is no explicit "system" rung. Storing nothing IS system, and that is the
default until someone chooses: the head script leaves `data-theme` unset and
the `prefers-color-scheme` fallback in `app.css` governs. A third button would
make the common case — never touching this at all — look like an unmade
decision.
The glyph shows the theme you would move to, not the one you are in, because
the control is an action rather than a status. Which glyph is visible cannot
be decided here: the effective theme depends on the visitor's OS when nothing
is stored, so the head script resolves it and the CSS picks the glyph.
"""
def theme_toggle(assigns) do
~H"""
<button
type="button"
class="btn theme-toggle"
data-variant="ghost"
data-size="sm"
aria-label="Toggle theme"
title="Toggle theme"
phx-click={JS.dispatch("phx:toggle-theme")}
>
<UI.icon name="sun" class="theme-toggle__sun" />
<UI.icon name="moon" class="theme-toggle__moon" />
</button>
"""
end
@doc """
The one authenticated identity control: an avatar trigger opening a bounded
native popover with the same identity and a labeled `LOG OUT` action.
"""
attr :current_user, :map, required: true
attr :context, :atom, values: [:bar, :row], default: :bar
def account_control(assigns) do
~H"""
<UI.button
id="account-menu-trigger"
variant={if(@context == :row, do: :ghost, else: :secondary)}
size={:sm}
class={["account-menu-trigger", @context == :row && "account-menu-trigger--row"]}
popovertarget="account-menu"
popovertargetaction="toggle"
aria-label={"Account menu for @#{@current_user.github_login}"}
>
<UI.avatar src={@current_user.github_avatar_url} size={:sm} />
<span :if={@context == :bar}>@{@current_user.github_login}</span>
<span :if={@context == :row} class="account-trigger-identity">
<strong>{account_display_name(@current_user)}</strong>
<small :if={@current_user.github_name} class="!text-[0.75rem]">@{@current_user.github_login}</small>
</span>
<UI.icon name="chevron-down" class="account-menu-trigger__caret" />
</UI.button>
<UI.menu id="account-menu" class="account-menu">
<div class="menu__identity">
<UI.avatar
src={@current_user.github_avatar_url}
alt={"GitHub avatar for @#{@current_user.github_login}"}
size={:lg}
/>
<span>
<strong>{account_display_name(@current_user)}</strong>
<small :if={@current_user.github_name}>@{@current_user.github_login}</small>
</span>
</div>
<.link navigate={~p"/settings/api-tokens"} role="menuitem" class="menu__item">
<UI.icon name="key" /> API tokens
</.link>
<.form for={%{}} id="logout-form" action={~p"/logout"} method="delete" class="menu__form">
<button id="logout" type="submit" role="menuitem" class="menu__item">
<UI.icon name="logout" /> Log out
</button>
</.form>
</UI.menu>
"""
end
# The command bar's identity control. Renders the same panel and the same
# rows as `account_control/1`. There were two account menus with two
# different sets of markup -- one on Tailwind utilities against
# `bg-popover`, one on the design system -- so the menu the chat surface
# showed and the menu the command bar showed did not look like the same
# application. There is one now.
attr :current_scope, :map, required: true
defp account_dropdown(assigns) do
~H"""
<UI.button
id="account-bar-trigger"
variant={:ghost}
size={:sm}
class="account-menu-trigger"
popovertarget="account-bar-menu"
popovertargetaction="toggle"
aria-label={"Account menu for @#{@current_scope.github_login}"}
>
<UI.avatar
src={@current_scope.github_avatar_url}
alt={"GitHub avatar for @#{@current_scope.github_login}"}
size={:sm}
/>
</UI.button>
<UI.menu id="account-bar-menu" class="account-menu">
<div class="menu__identity">
<UI.avatar
src={@current_scope.github_avatar_url}
alt={"GitHub avatar for @#{@current_scope.github_login}"}
size={:lg}
/>
<span>
<strong>{account_display_name(@current_scope)}</strong>
<small :if={@current_scope.github_name}>@{@current_scope.github_login}</small>
</span>
</div>
<.link navigate={~p"/settings/api-tokens"} role="menuitem" class="menu__item">
<UI.icon name="key" /> API tokens
</.link>
<.form for={%{}} as={:logout} action={~p"/logout"} method="delete" class="menu__form">
<button type="submit" role="menuitem" class="menu__item">
<UI.icon name="logout" /> Log out
</button>
</.form>
</UI.menu>
"""
end
attr :current_scope, :map, required: true
attr :sidebar_sections, :map, required: true
slot :extra, doc: "rows contributed by the current page"
defp sidebar(assigns) do
assigns =
assigns
|> assign(:agent_surfaces?, agent_surfaces?(assigns[:current_scope]))
|> assign(:operator?, admin?(assigns[:current_scope]))
~H"""
<aside id="sidebar" class="sidebar" aria-hidden="true">
<div class="sidebar-app-header">
<Layouts.sidebar_brand />
<UI.button
id="sidebar-collapse-toggle"
class="sidebar-toggle sidebar-toggle--collapse"
variant={:ghost}
size={:sm}
data-sidebar-toggle
aria-label="Collapse navigation sidebar"
aria-controls="sidebar"
aria-expanded="false"
title="Collapse navigation sidebar"
>
<UI.icon name="sidebar-left" />
</UI.button>
</div>
<nav class="sidebar-nav" aria-label="OpenAgents surfaces">
<Layouts.sidebar_link path={~p"/"} label="Home" icon="home" patchable={false} />
<Layouts.sidebar_link
:if={@operator?}
path={~p"/chat"}
label="Chat"
icon="chats"
patchable={false}
/>
<Layouts.sidebar_link
path={~p"/repositories"}
label="Repositories"
icon="branch"
patchable={false}
/>
<%!-- Global, and unconditional. These rows used to address whichever
repository the current page named, falling back to the first repository
in the reader's workspace alphabetically, so the same row led somewhere
different depending on where it was clicked from — and vanished
entirely for an account with no membership. A row in the app's own
navigation has to mean one thing everywhere, so it addresses everything
the reader can see. The repository-scoped lists keep their place in the
repository's own tabs, which is where a repository-scoped nav
belongs. --%>
<Layouts.sidebar_link path={~p"/issues"} label="Issues" icon="bug" patchable={false} />
<Layouts.sidebar_link
path={~p"/projects"}
label="Projects"
icon="folder"
patchable={false}
/>
<%!-- Operator-only: the Gym is a workbench for the people building
the agent, not a public leaderboard yet. The operator allowlist IS
the whitelist, deliberately, rather than a second gating
mechanism. --%>
<Layouts.sidebar_link
:if={@operator?}
path={~p"/gym"}
label="Gym"
icon="dumbbell"
patchable={false}
/>
</nav>
<%!-- The agent's own surfaces, grouped under her name. Chat, computers
and memory are one thing from the reader's side -- the conversation and
the two things it can reach -- and reading as a group says that in a way
six flat rows cannot. Open by default: grouping is for orientation here,
not for hiding. --%>
<Layouts.sidebar_section
:if={@agent_surfaces?}
title="Sarah"
open
state={@sidebar_sections}
>
<Layouts.sidebar_link path={~p"/sarah"} label="Chat" icon="chat" patchable={false} />
<Layouts.sidebar_link
path={~p"/computers"}
label="Computers"
icon="desktop"
patchable={false}
/>
<Layouts.sidebar_link
path={~p"/memory"}
label="Memory"
icon="brain"
patchable={false}
/>
</Layouts.sidebar_section>
<%!-- Rows the current page contributes. Chat's destinations, work
projections and admin actions arrive here instead of in a second rail. --%>
{render_slot(@extra)}
<Layouts.sidebar_footer current_user={@current_scope} />
</aside>
"""
end
# The agent's surfaces are grandfathered, not launched: an account that has
# already talked to her keeps them, and an operator always has them, but a
# new account never sees them. `agent_surfaces?` is resolved once by
# `UserAuth.on_mount/4`; this runs on every render, so it must stay a field
# read and never become a query.
defp agent_surfaces?(nil), do: false
defp agent_surfaces?(user), do: user.agent_surfaces? or admin?(user)
# A nil scope is not an operator. The footer renders on public pages too.
defp admin?(nil), do: false
defp admin?(user), do: OpenAgents.Accounts.admin?(user)
@doc """
The browser title: the page's own name, then the brand.
Returns nil when the page has no name, so `live_title`'s default renders the
brand alone instead of appending it to itself.
"""
def page_title(assigns) do
case assigns[:page_title] do
title when is_binary(title) and title != "" -> title <> " · OpenAgents"
_absent -> nil
end
end
@doc """
The `og:*` / `twitter:*` block for the root layout.
Views that build an `OpenAgentsWeb.OG` card assign `:og` (via `OG.meta/2`)
and this renders it; every other page gets honest site-level tags rather
than nothing. Crawlers read the initial server-rendered HTML, so these tags
ride the first paint only — exactly where they are consumed.
"""
attr :og, :map, default: nil, doc: "an `OpenAgentsWeb.OG.meta/2` map"
def og_tags(assigns) do
og =
assigns[:og] ||
%{
title: "OpenAgents",
description: "Code hosting, issues, and projects on the agent-native forge.",
type: "website",
url: OpenAgentsWeb.OG.site_url(),
image_url: OpenAgentsWeb.OG.static_card_url(),
alt: "OpenAgents — code hosting, issues, and projects."
}
assigns = assign(assigns, :og, og)
~H"""
<meta property="og:site_name" content="OpenAgents" />
<meta property="og:type" content={@og.type} />
<meta property="og:title" content={@og.title} />
<meta property="og:description" content={@og.description} />
<meta property="og:url" content={@og.url} />
<meta property="og:image" content={@og.image_url} />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content={@og.alt} />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={@og.title} />
<meta name="twitter:description" content={@og.description} />
<meta name="twitter:image" content={@og.image_url} />
"""
end
@doc """
One browser analytics identity field from the session-written map, or nil.
The root layout renders these as data attributes for `app.js`; a missing or
malformed identity renders nothing rather than raising.
"""
def posthog_identity(assigns, key) when is_atom(key) do
case assigns[:posthog_identity] do
%{} = identity ->
case identity[Atom.to_string(key)] do
value when is_binary(value) and value != "" -> value
_other -> nil
end
_absent ->
nil
end
end
defp account_display_name(%{github_name: name}) when is_binary(name) and name != "", do: name
defp account_display_name(%{github_login: login}), do: "@" <> login
@doc """
Shows the flash group with standard titles and content.
## Examples
<.flash_group flash={@flash} />
"""
attr :flash, :map, required: true, doc: "the map of flash messages"
attr :id, :string, default: "flash-group", doc: "the optional id of flash container"
attr :class, :any, default: nil, doc: "additional classes for the container"
def flash_group(assigns) do
~H"""
<div id={@id} class={@class} aria-live="polite">
<.flash kind={:info} flash={@flash} />
<.flash kind={:error} flash={@flash} />
<.flash
id="client-error"
kind={:error}
title={gettext("We can't find the internet")}
phx-disconnected={
show(".phx-client-error #client-error")
|> JS.remove_attribute("hidden", to: ".phx-client-error #client-error")
}
phx-connected={hide("#client-error") |> JS.set_attribute({"hidden", ""})}
hidden
>
{gettext("Attempting to reconnect")}
<.icon name="arrow-rotate-cw" class="ml-1 size-3 motion-safe:animate-spin" />
</.flash>
<.flash
id="server-error"
kind={:error}
title={gettext("Something went wrong!")}
phx-disconnected={
show(".phx-server-error #server-error")
|> JS.remove_attribute("hidden", to: ".phx-server-error #server-error")
}
phx-connected={hide("#server-error") |> JS.set_attribute({"hidden", ""})}
hidden
>
{gettext("Attempting to reconnect")}
<.icon name="arrow-rotate-cw" class="ml-1 size-3 motion-safe:animate-spin" />
</.flash>
</div>
"""
end
attr :id, :string, default: nil
attr :flash, :map, default: %{}
attr :title, :string, default: nil
attr :kind, :atom, values: [:info, :error], required: true
attr :rest, :global
slot :inner_block
defp flash(assigns) do
assigns = assign_new(assigns, :id, fn -> "flash-#{assigns.kind}" end)
~H"""
<div
:if={message = render_slot(@inner_block) || Phoenix.Flash.get(@flash, @kind)}
id={@id}
phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
role="alert"
class="fixed top-4 right-4 z-50 flex flex-col items-end gap-2"
{@rest}
>
<UI.alert
class="w-80 max-w-80 text-wrap sm:w-96 sm:max-w-96"
variant={if(@kind == :error, do: :danger, else: :info)}
label={@title}
>
{message}
<:action>
<UI.button
type="button"
variant={:ghost}
size={:xs}
aria-label={gettext("Dismiss notice")}
>
<UI.icon name="x" />
</UI.button>
</:action>
</UI.alert>
</div>
"""
end
defp show(js \\ %JS{}, selector) do
JS.show(js,
to: selector,
time: 300,
transition:
{"transition-all transform ease-out duration-300", "opacity-0 translate-y-4",
"opacity-100 translate-y-0"}
)
end
defp hide(js \\ %JS{}, selector) do
JS.hide(js,
to: selector,
time: 200,
transition:
{"transition-all transform ease-in duration-200", "opacity-100 translate-y-0",
"opacity-0 translate-y-4"}
)
end
end