Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 123 additions & 0 deletions packages/live_ui/lib/live_ui/renderer.ex
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ defmodule LiveUi.Renderer do
:field,
:field_group,
:file_input,
:file_tree_browser,
:form_builder,
:gauge,
:grid,
Expand Down Expand Up @@ -70,6 +71,7 @@ defmodule LiveUi.Renderer do
:tabs,
:text,
:text_input,
:thread_card,
:time_input,
:toast,
:toggle,
Expand Down Expand Up @@ -271,6 +273,103 @@ defmodule LiveUi.Renderer do
"""
end

# NOTE: `:thread_card` is a member of `@content_identity_kinds` (and therefore
# of `@component_kinds`), so this specific clause MUST appear BEFORE the generic
# `@component_kinds` fallback to ensure the dedicated ThreadCard component is
# invoked instead of the generic section shell. Mirrors the `:command_palette`
# pattern above.
def render(%{element: %Element{kind: :thread_card}} = assigns) do
interaction_attrs = interaction_event_attrs(assigns.element, Map.get(assigns, :event_target))

assigns =
assign(assigns, :interaction_attrs, interaction_attrs)
|> assign(:style_attrs, merge_global_attrs(style_rest(assigns.element), interaction_attrs))

~H"""
<LiveUi.Widgets.ThreadCard.component
id={element_id(@element, "thread-card")}
thread_id={string_value(get_in(@element.attributes, [:thread, :thread_id]), "")}
title={string_value(get_in(@element.attributes, [:thread, :title]), "")}
reply_count={integer_value(get_in(@element.attributes, [:thread, :reply_count]), 0)}
seed_quote={string_value(get_in(@element.attributes, [:thread, :seed_quote]), "")}
participants={get_in(@element.attributes, [:participants]) || []}
progress_pct={get_in(@element.attributes, [:thread, :progress_pct])}
last_activity_at={get_in(@element.attributes, [:thread, :last_activity_at])}
open_intent={string_value(get_in(@element.attributes, [:thread, :open_intent]), "open_thread")}
tone={style_tone(@element)}
variant={theme_variant(@element)}
state={style_state(@element)}
class={style_class(@element)}
{@style_attrs}
/>
"""
end

# NOTE: `:composer_inline_ask` is a member of `@layer_callout_kinds` (and
# therefore of `@component_kinds`). This specific clause MUST appear BEFORE
# the generic `@component_kinds` fallback to route to the dedicated
# ComposerInlineAsk Phoenix.Component. Mirrors the `:command_palette` and
# `:thread_card` patterns above.
def render(%{element: %Element{kind: :composer_inline_ask}} = assigns) do
assigns = assign(assigns, :style_attrs, style_rest(assigns.element))

~H"""
<LiveUi.Widgets.ComposerInlineAsk.component
id={element_id(@element, "composer-inline-ask")}
composer_id={string_value(get_in(@element.attributes, [:ask_preview, :composer_id]), "")}
ask_query={string_value(get_in(@element.attributes, [:ask_preview, :ask_query]), "")}
preview_state={get_in(@element.attributes, [:ask_preview, :preview_state]) || :empty}
on_dismiss={string_value(get_in(@element.attributes, [:ask_preview, :on_dismiss]), "dismiss")}
on_open_in_ask={string_value(get_in(@element.attributes, [:ask_preview, :on_open_in_ask]), "open_in_ask")}
on_save_query={string_value(get_in(@element.attributes, [:ask_preview, :on_save_query]), "save_query")}
explain={string_optional(get_in(@element.attributes, [:ask_preview, :explain]))}
meta={get_in(@element.attributes, [:ask_preview, :meta])}
preview_findings={get_in(@element.attributes, [:ask_preview, :preview_findings]) || []}
max_findings_shown={integer_value(get_in(@element.attributes, [:ask_preview, :max_findings_shown]), 2)}
error_message={string_optional(get_in(@element.attributes, [:ask_preview, :error_message]))}
event_target={string_optional(get_in(@element.attributes, [:ask_preview, :event_target]))}
loading_label={string_value(get_in(@element.attributes, [:ask_preview, :loading_label]), "Querying…")}
tone={style_tone(@element)}
variant={theme_variant(@element)}
state={style_state(@element)}
class={style_class(@element)}
{@style_attrs}
/>
"""
end

# NOTE: `:ask_sidebar` is a member of `@layer_callout_kinds` (and therefore of
# `@component_kinds`). This specific clause MUST appear BEFORE the generic
# `@component_kinds` fallback to route to the dedicated AskSidebar
# Phoenix.Component. Mirrors the `:command_palette`, `:thread_card`, and
# `:composer_inline_ask` patterns above.
def render(%{element: %Element{kind: :ask_sidebar}} = assigns) do
ask_sidebar_attrs = get_in(assigns.element.attributes, [:ask_sidebar]) || %{}
assigns = assign(assigns, :ask_sidebar_attrs, ask_sidebar_attrs)
assigns = assign(assigns, :style_attrs, style_rest(assigns.element))

~H"""
<LiveUi.Widgets.AskSidebar.component
id={element_id(@element, "ask-sidebar")}
sidebar_id={string_value(Map.get(@ask_sidebar_attrs, :sidebar_id), "")}
on_map_jump_event={string_value(Map.get(@ask_sidebar_attrs, :on_map_jump_event), "")}
recent_items={Map.get(@ask_sidebar_attrs, :recent_items) || []}
saved_items={Map.get(@ask_sidebar_attrs, :saved_items) || []}
active_item_id={string_optional(Map.get(@ask_sidebar_attrs, :active_item_id))}
on_new_saved_event={string_optional(Map.get(@ask_sidebar_attrs, :on_new_saved_event))}
on_see_all_event={string_optional(Map.get(@ask_sidebar_attrs, :on_see_all_event))}
empty_recent_label={string_value(Map.get(@ask_sidebar_attrs, :empty_recent_label), "No recent queries")}
empty_saved_label={string_value(Map.get(@ask_sidebar_attrs, :empty_saved_label), "No saved queries yet")}
blocker_count={Map.get(@ask_sidebar_attrs, :blocker_count) || 0}
tone={style_tone(@element)}
variant={theme_variant(@element)}
state={style_state(@element)}
class={style_class(@element)}
{@style_attrs}
/>
"""
end

def render(%{element: %Element{kind: kind}} = assigns) when kind in @component_kinds do
assigns = assign(assigns, :style_attrs, style_rest(assigns.element))

Expand Down Expand Up @@ -837,6 +936,30 @@ defmodule LiveUi.Renderer do
"""
end

def render(%{element: %Element{kind: :file_tree_browser}} = assigns) do
assigns = assign(assigns, :style_attrs, style_rest(assigns.element))

~H"""
<LiveUi.Widgets.FileTreeBrowser.component
id={element_id(@element, "file-tree-browser")}
tree_id={string_value(get_in(@element.attributes, [:file_tree, :tree_id]), "ftb")}
root_label={string_value(get_in(@element.attributes, [:file_tree, :root_label]), "")}
nodes={get_in(@element.attributes, [:file_tree, :nodes]) || []}
selected_path={string_optional(get_in(@element.attributes, [:file_tree, :selected_path]))}
on_select={string_optional(get_in(@element.attributes, [:file_tree, :on_select]))}
on_toggle={string_optional(get_in(@element.attributes, [:file_tree, :on_toggle]))}
default_expanded={boolean_default(get_in(@element.attributes, [:file_tree, :default_expanded]), true)}
depth_indent_px={integer_value(get_in(@element.attributes, [:file_tree, :depth_indent_px]), 12)}
event_target={@event_target}
tone={style_tone(@element)}
variant={theme_variant(@element)}
state={style_state(@element)}
class={style_class(@element)}
{@style_attrs}
/>
"""
end

def render(%{element: %Element{kind: :status}} = assigns) do
assigns = assign(assigns, :style_attrs, style_rest(assigns.element))

Expand Down
188 changes: 188 additions & 0 deletions packages/live_ui/lib/live_ui/widgets/thread_card.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
defmodule LiveUi.Widgets.ThreadCard do
@moduledoc """
Native thread-card widget.

Renders a rich preview card for a conversation thread reference. Used wherever
a thread is mentioned in another conversation's feed (Chat references section,
Talk cross-thread mentions, Map per-repo activity), showing enough context that
the operator can decide whether to navigate into it without doing so.

## Attributes

Required:
- `:thread_id` - the conversation/thread identifier (data selector hook)
- `:title` - the thread's display title
- `:reply_count` - total replies in the thread
- `:seed_quote` - a pull quote from the thread's opening message

Optional:
- `:participants` - list of `%{avatar: avatar_attrs, actor_name: string}` maps for
the avatar stack; at most 3 are shown with an overflow indicator
- `:progress_pct` - float 0.0–1.0; when present renders an inline progress bar
- `:last_activity_at` - DateTime for relative-time subtext
- `:open_intent` - canonical Interaction intent for the open action (default "open_thread")

## Selector / hook contract

Root element: `data-live-ui-widget="thread-card"` + `data-thread-id="{id}"`.
Avatar stack: `.live-ui-thread-card__avatars` + `.live-ui-thread-card__avatar` per BEM.
Inline progress: `.live-ui-thread-card__progress` + `[data-progress-pct]`.
Open action: `.live-ui-thread-card__open` + `aria-label="Open thread: {title}"`.
Avatar overflow: `.live-ui-thread-card__avatar-overflow` + aria-label.

## ARIA

- The open-thread button: `aria-label="Open thread: {title}"`.
- Avatar overflow span: `aria-label="and N more participants"`.
- Progress bar (when present): `role="progressbar" aria-valuenow aria-valuemin aria-valuemax`.
- The article root uses an implicit `region` role (no explicit ARIA needed).

## Open questions for Pascal (Wave 3.7-B)

1. **Family assignment** — placed in `:content_identity_and_disclosure` per spec draft; Pascal
may prefer a new `:conversation_artifact` family or another existing family.
2. **Participants as attr vs children** — kept in element attrs (not as child IUR nodes) since
participants aren't independently interactive in v1. Pascal may prefer child `:avatar` nodes
for composability.
3. **Seed-quote truncation** — truncation is render-side here (via CSS or explicit slice in the
template). Pascal may want a constructor-enforced max-length.
4. **Progress bar reuse** — inline progress is bespoke in this template (not a child `:progress`
IUR widget). Reuse via a nested `LiveUi.Widgets.Progress` would be more compositional.
5. **"Open →" affordance** — text+arrow string is locked in the renderer here; Pascal may want it
themable or icon-based.
"""

use LiveUi.Component, family: :content, name: :thread_card, events: [:click]

LiveUi.Component.common_attrs()
attr(:thread_id, :string, required: true)
attr(:title, :string, required: true)
attr(:reply_count, :integer, default: 0)
attr(:seed_quote, :string, default: "")
attr(:participants, :list, default: [])
attr(:progress_pct, :float, default: nil)
attr(:last_activity_at, :any, default: nil)
attr(:open_intent, :string, default: "open_thread")

@impl true
def render(assigns) do
~H"""
<article
id={@id}
data-live-ui-widget="thread-card"
data-thread-id={@thread_id}
data-live-ui-tone={@tone}
data-live-ui-variant={@variant}
data-live-ui-state={@state}
class={["live-ui-thread-card", @class]}
{@rest}
>
<header class="live-ui-thread-card__header">
<div
class="live-ui-thread-card__avatars"
aria-hidden="true"
>
<%= for participant <- Enum.take(@participants, 3) do %>
<span
class="live-ui-thread-card__avatar"
title={Map.get(participant, :actor_name) || Map.get(participant, "actor_name") || ""}
>
{participant_initials(participant)}
</span>
<% end %>
<%= if length(@participants) > 3 do %>
<span
class="live-ui-thread-card__avatar-overflow"
aria-label={"and #{length(@participants) - 3} more participants"}
>
+{length(@participants) - 3}
</span>
<% end %>
</div>
<h3 class="live-ui-thread-card__title">{@title}</h3>
</header>

<blockquote class="live-ui-thread-card__seed-quote">{@seed_quote}</blockquote>

<%= if @progress_pct do %>
<div
class="live-ui-thread-card__progress"
role="progressbar"
aria-valuenow={trunc(@progress_pct * 100)}
aria-valuemin="0"
aria-valuemax="100"
data-progress-pct={@progress_pct}
>
<div
class="live-ui-thread-card__progress-fill"
style={"width: #{trunc(@progress_pct * 100)}%"}
/>
</div>
<% end %>

<footer class="live-ui-thread-card__footer">
<span class="live-ui-thread-card__meta">
{@reply_count} {ngettext_fallback(@reply_count)}<%= if @last_activity_at do %> · {relative_time(@last_activity_at)}<% end %>
</span>
<button
type="button"
class="live-ui-thread-card__open"
aria-label={"Open thread: #{@title}"}
data-live-ui-intent={@open_intent}
data-live-ui-value={@thread_id}
{@rest}
>
open →
</button>
</footer>
</article>
"""
end

# ---------------------------------------------------------------------------
# Private helpers
# ---------------------------------------------------------------------------

defp participant_initials(participant) do
actor_name =
Map.get(participant, :actor_name) ||
Map.get(participant, "actor_name") || ""

avatar = Map.get(participant, :avatar) || Map.get(participant, "avatar") || %{}
initials = Map.get(avatar, :initials) || Map.get(avatar, "initials")

cond do
is_binary(initials) and initials != "" ->
initials

is_binary(actor_name) and actor_name != "" ->
actor_name
|> String.split()
|> Enum.take(2)
|> Enum.map(&String.first/1)
|> Enum.join()
|> String.upcase()

true ->
"?"
end
end

defp ngettext_fallback(1), do: "reply"
defp ngettext_fallback(_), do: "replies"

defp relative_time(%DateTime{} = dt) do
now = DateTime.utc_now()
diff_seconds = DateTime.diff(now, dt, :second)

cond do
diff_seconds < 60 -> "just now"
diff_seconds < 3600 -> "#{div(diff_seconds, 60)}m ago"
diff_seconds < 86_400 -> "#{div(diff_seconds, 3600)}h ago"
diff_seconds < 604_800 -> "#{div(diff_seconds, 86_400)}d ago"
true -> "#{div(diff_seconds, 604_800)}w ago"
end
end

defp relative_time(_other), do: ""
end
Loading
Loading