diff --git a/lib/bds/ai/chat.ex b/lib/bds/ai/chat.ex index 96e1807..1a4063f 100644 --- a/lib/bds/ai/chat.ex +++ b/lib/bds/ai/chat.ex @@ -527,13 +527,26 @@ defmodule BDS.AI.Chat do Enum.join( [ "Available blog data tools:", - "- Use blog_stats for aggregate counts of posts, media, tags, and categories.", + "- Use get_blog_stats for aggregate counts of posts, media, tags, and categories.", "- Use search_posts for full-text blog search and filtered post lookup by category, tag, language, year, month, or status.", + "- Use read_post to read a post by ID, or read_post_by_slug to read a post by slug.", "- Use read_post_by_slug to read full post content and metadata when a slug is known.", "- Use list_posts when asked for post titles, slugs, URLs, statuses, backlinks, or recent/top/latest post lists. This is allowed project data access.", - "- Use list_media when asked for media titles, filenames, MIME types, or recent media lists. This is allowed project data access.", + "- Use get_media for one media item by ID, list_media for media titles, filenames, MIME types, or recent media lists, and view_image for visual image inspection.", + "- Use update_post_metadata and update_media_metadata when asked to change titles, excerpts, tags, categories, alt text, or captions.", + "- Use get_post_backlinks, get_post_outlinks, get_post_media, and get_media_posts for relationship questions.", "- Use list_tags, list_categories, and count_posts for taxonomy and grouped analytics questions.", - "If a requested blog fact is available through these tools, call the tool instead of saying you cannot access the data." + "If a requested blog fact is available through these tools, call the tool instead of saying you cannot access the data.", + "", + "Available UI Render Tools:", + "- Use render_chart to show data as a bar, stacked-bar, line, area, pie, donut, or heatmap chart. Use it when presenting statistics or comparisons. Prefer heatmap over tables with emoji or color indicators for intensity grids or calendar-style activity.", + "- Use render_table for tabular data, comparisons, and structured listings.", + "- Use render_form to collect structured user input.", + "- Use render_card for summaries, highlights, or actionable items.", + "- Use render_metric for a single KPI or important statistic.", + "- Use render_list for bullet lists, checklists, or simple enumerations.", + "- Use render_tabs to organize multiple views into switchable tabs; tab content can contain text, metrics, lists, charts, and tables.", + "When presenting data, statistics, or comparisons, prefer render tools over plain text. When building any visualization, render it as soon as you have enough data." ], "\n" ) diff --git a/lib/bds/ai/chat_tools.ex b/lib/bds/ai/chat_tools.ex index 563567b..7120c9a 100644 --- a/lib/bds/ai/chat_tools.ex +++ b/lib/bds/ai/chat_tools.ex @@ -4,15 +4,22 @@ defmodule BDS.AI.ChatTools do import Ecto.Query alias BDS.AI.Chat + alias BDS.Media, as: MediaContext alias BDS.Media.Media alias BDS.MCP.Queries + alias BDS.Posts, as: PostsContext alias BDS.Posts.Post + alias BDS.Posts.PostMedia alias BDS.Projects.Project alias BDS.Repo alias BDS.Search @spec execute(String.t(), map(), String.t() | nil) :: map() def execute("blog_stats", _arguments, project_id) do + execute("get_blog_stats", %{}, project_id) + end + + def execute("get_blog_stats", _arguments, project_id) do project_id = project_id || active_project_id() %{ @@ -73,6 +80,18 @@ defmodule BDS.AI.ChatTools do end end + def execute("read_post", arguments, project_id) do + project_id = project_id || active_project_id() + + case Repo.get_by(Post, + id: arguments["postId"] || arguments["post_id"], + project_id: project_id + ) do + %Post{} = post -> %{post: Queries.post_detail(post)} + nil -> %{success: false, error: "not_found"} + end + end + def execute("list_posts", arguments, project_id) do project_id = project_id || active_project_id() limit = normalize_limit(arguments["limit"]) @@ -116,6 +135,70 @@ defmodule BDS.AI.ChatTools do ) end + def execute("get_media", arguments, project_id) do + project_id = project_id || active_project_id() + + case Repo.get_by(Media, + id: arguments["mediaId"] || arguments["media_id"], + project_id: project_id + ) do + %Media{} = media -> %{media: media_summary(media)} + nil -> %{success: false, error: "not_found"} + end + end + + def execute("view_image", arguments, project_id) do + project_id = project_id || active_project_id() + media_id = arguments["mediaId"] || arguments["media_id"] + size = arguments["size"] || "medium" + + case Repo.get_by(Media, id: media_id, project_id: project_id) do + %Media{mime_type: "image/" <> _rest} = media -> + case thumbnail_data_url(project_id, media, size) do + nil -> %{success: false, error: "thumbnail_not_available"} + data_url -> %{success: true, media: media_summary(media), data_url: data_url} + end + + %Media{} = media -> + %{success: false, error: "not_image", mime_type: media.mime_type} + + nil -> + %{success: false, error: "not_found"} + end + end + + def execute("update_post_metadata", arguments, project_id) do + project_id = project_id || active_project_id() + post_id = arguments["postId"] || arguments["post_id"] + + with %Post{} <- Repo.get_by(Post, id: post_id, project_id: project_id), + attrs <- metadata_attrs(arguments, ["title", "excerpt", "tags", "categories"]), + false <- attrs == %{}, + {:ok, post} <- PostsContext.update_post(post_id, attrs) do + %{success: true, post: Queries.post_detail(post)} + else + nil -> %{success: false, error: "not_found"} + true -> %{success: false, error: "no_updates_provided"} + {:error, reason} -> %{success: false, error: inspect(reason)} + end + end + + def execute("update_media_metadata", arguments, project_id) do + project_id = project_id || active_project_id() + media_id = arguments["mediaId"] || arguments["media_id"] + + with %Media{} <- Repo.get_by(Media, id: media_id, project_id: project_id), + attrs <- metadata_attrs(arguments, ["title", "alt", "caption", "tags"]), + false <- attrs == %{}, + {:ok, media} <- MediaContext.update_media(media_id, attrs) do + %{success: true, media: media_summary(media)} + else + nil -> %{success: false, error: "not_found"} + true -> %{success: false, error: "no_updates_provided"} + {:error, reason} -> %{success: false, error: inspect(reason)} + end + end + def execute("list_tags", _arguments, project_id) do project_id = project_id || active_project_id() @@ -149,6 +232,56 @@ defmodule BDS.AI.ChatTools do %{groups: groups, total_posts: result.total} end + def execute("get_post_backlinks", arguments, project_id) do + project_id = project_id || active_project_id() + + case Repo.get_by(Post, + id: arguments["postId"] || arguments["post_id"], + project_id: project_id + ) do + %Post{} = post -> + %{success: true, post_id: post.id, linked_by: Queries.linked_posts(post.id, :incoming)} + + nil -> + %{success: false, error: "not_found"} + end + end + + def execute("get_post_outlinks", arguments, project_id) do + project_id = project_id || active_project_id() + + case Repo.get_by(Post, + id: arguments["postId"] || arguments["post_id"], + project_id: project_id + ) do + %Post{} = post -> + %{success: true, post_id: post.id, links_to: Queries.linked_posts(post.id, :outgoing)} + + nil -> + %{success: false, error: "not_found"} + end + end + + def execute("get_post_media", arguments, project_id) do + project_id = project_id || active_project_id() + post_id = arguments["postId"] || arguments["post_id"] + + case Repo.get_by(Post, id: post_id, project_id: project_id) do + %Post{} = post -> %{success: true, post_id: post.id, media: post_media(project_id, post.id)} + nil -> %{success: false, error: "not_found"} + end + end + + def execute("get_media_posts", arguments, project_id) do + project_id = project_id || active_project_id() + media_id = arguments["mediaId"] || arguments["media_id"] + + case Repo.get_by(Media, id: media_id, project_id: project_id) do + %Media{} = media -> %{success: true, media_id: media.id, posts: media_posts(media.id)} + nil -> %{success: false, error: "not_found"} + end + end + def execute("render_table", arguments, _project_id) do %{ type: "table", @@ -162,7 +295,7 @@ defmodule BDS.AI.ChatTools do %{ type: "chart", title: arguments["title"], - chart_type: arguments["chart_type"] || "bar", + chart_type: arguments["chartType"] || arguments["chart_type"] || "bar", series: arguments["series"] || [] } end @@ -237,6 +370,15 @@ defmodule BDS.AI.ChatTools do "properties" => %{} }) }, + %{ + name: "get_blog_stats", + spec: + tool_spec( + "get_blog_stats", + "Get comprehensive blog statistics: total posts, media count, unique tag count, and unique category count. Use this first when you need to understand the scope of the data.", + %{"type" => "object", "properties" => %{}} + ) + }, %{ name: "check_term", spec: @@ -259,6 +401,19 @@ defmodule BDS.AI.ChatTools do post_search_schema(true) ) }, + %{ + name: "read_post", + spec: + tool_spec( + "read_post", + "Read full content and metadata of a specific blog post by ID. Includes backlinks, links_to, tags, categories, excerpt, status, language, and available languages.", + %{ + "type" => "object", + "properties" => %{"postId" => %{"type" => "string"}}, + "required" => ["postId"] + } + ) + }, %{ name: "read_post_by_slug", spec: @@ -281,6 +436,15 @@ defmodule BDS.AI.ChatTools do post_search_schema(false) ) }, + %{ + name: "get_media", + spec: + tool_spec( + "get_media", + "Get information about a specific media file by ID, including title, alt text, caption, tags, filename, MIME type, dimensions, and update time.", + media_id_schema() + ) + }, %{ name: "list_media", spec: @@ -290,6 +454,35 @@ defmodule BDS.AI.ChatTools do limit_schema() ) }, + %{ + name: "view_image", + spec: + tool_spec( + "view_image", + "View an image thumbnail as a local data URL for visual inspection. Only works with image media files.", + media_id_schema(%{ + "size" => %{"type" => "string", "enum" => ["small", "medium", "large"]} + }) + ) + }, + %{ + name: "update_post_metadata", + spec: + tool_spec( + "update_post_metadata", + "Update metadata for a blog post: title, excerpt, tags, or categories. Does not update post body content.", + update_post_metadata_schema() + ) + }, + %{ + name: "update_media_metadata", + spec: + tool_spec( + "update_media_metadata", + "Update metadata for a media file: title, alt text, caption, or tags.", + update_media_metadata_schema() + ) + }, %{ name: "list_tags", spec: @@ -319,6 +512,42 @@ defmodule BDS.AI.ChatTools do "Count posts grouped by dimensions such as year, month, tag, category, or status. Use for analytics, distributions, and heat maps without transferring full post content.", count_posts_schema() ) + }, + %{ + name: "get_post_backlinks", + spec: + tool_spec( + "get_post_backlinks", + "Get all posts that link to a specific post.", + post_id_schema() + ) + }, + %{ + name: "get_post_outlinks", + spec: + tool_spec( + "get_post_outlinks", + "Get all posts that a specific post links to.", + post_id_schema() + ) + }, + %{ + name: "get_post_media", + spec: + tool_spec( + "get_post_media", + "Get media files linked to a specific post.", + post_id_schema() + ) + }, + %{ + name: "get_media_posts", + spec: + tool_spec( + "get_media_posts", + "Get posts that use a specific media file.", + media_id_schema() + ) } ] else @@ -330,14 +559,18 @@ defmodule BDS.AI.ChatTools do %{ name: "render_card", spec: - tool_spec("render_card", "Return a structured card payload", render_card_schema()) + tool_spec( + "render_card", + "Render an information card in the chat UI. Use this for displaying a summary, highlight, or actionable item.", + render_card_schema() + ) }, %{ name: "render_table", spec: tool_spec( "render_table", - "Return a structured table payload", + "Render a data table in the chat UI. Use this when the user asks for tabular data, comparisons, or structured information.", render_table_schema() ) }, @@ -346,40 +579,52 @@ defmodule BDS.AI.ChatTools do spec: tool_spec( "render_chart", - "Return a structured chart payload", + "Render an interactive chart in the chat UI. Use this when the user asks for a chart, graph, or data visualization. Supports bar, stacked-bar, line, area, pie, donut, and heatmap charts. Use stacked-bar for multi-segment bars and heatmap for grid/matrix visualizations.", render_chart_schema() ) }, %{ name: "render_form", spec: - tool_spec("render_form", "Return a structured form payload", render_form_schema()) + tool_spec( + "render_form", + "Render an interactive form in the chat UI. Use this when you need to collect structured input from the user.", + render_form_schema() + ) }, %{ name: "render_metric", spec: tool_spec( "render_metric", - "Return a structured metric payload", + "Render a single metric/KPI display in the chat UI. Use this for showing a single important value with a label.", render_metric_schema() ) }, %{ name: "render_list", spec: - tool_spec("render_list", "Return a structured list payload", render_list_schema()) + tool_spec( + "render_list", + "Render a list of items in the chat UI. Use this for displaying bullet-point style lists, checklists, or simple enumerations.", + render_list_schema() + ) }, %{ name: "render_tabs", spec: - tool_spec("render_tabs", "Return a structured tabs payload", render_tabs_schema()) + tool_spec( + "render_tabs", + "Render a tabbed interface in the chat UI. Use this to organize information into multiple tabs that the user can switch between.", + render_tabs_schema() + ) }, %{ name: "render_mindmap", spec: tool_spec( "render_mindmap", - "Return a structured mindmap payload", + "Render a mind map diagram in the chat UI. Use this when the user asks for a mind map, concept map, topic tree, brainstorming diagram, or hierarchical overview of ideas.", render_mindmap_schema() ) } @@ -450,13 +695,65 @@ defmodule BDS.AI.ChatTools do } end + defp post_id_schema do + %{ + "type" => "object", + "properties" => %{"postId" => %{"type" => "string"}}, + "required" => ["postId"] + } + end + + defp media_id_schema(extra_properties \\ %{}) do + %{ + "type" => "object", + "properties" => Map.merge(%{"mediaId" => %{"type" => "string"}}, extra_properties), + "required" => ["mediaId"] + } + end + + defp update_post_metadata_schema do + %{ + "type" => "object", + "properties" => %{ + "postId" => %{"type" => "string"}, + "title" => %{"type" => "string"}, + "excerpt" => %{"type" => "string"}, + "tags" => %{"type" => "array", "items" => %{"type" => "string"}}, + "categories" => %{"type" => "array", "items" => %{"type" => "string"}} + }, + "required" => ["postId"] + } + end + + defp update_media_metadata_schema do + %{ + "type" => "object", + "properties" => %{ + "mediaId" => %{"type" => "string"}, + "title" => %{"type" => "string"}, + "alt" => %{"type" => "string"}, + "caption" => %{"type" => "string"}, + "tags" => %{"type" => "array", "items" => %{"type" => "string"}} + }, + "required" => ["mediaId"] + } + end + defp render_table_schema do %{ "type" => "object", "properties" => %{ - "title" => %{"type" => "string"}, - "columns" => %{"type" => "array"}, - "rows" => %{"type" => "array"} + "title" => %{"type" => "string", "description" => "Optional table title"}, + "columns" => %{ + "type" => "array", + "items" => %{"type" => "string"}, + "description" => "Column header names" + }, + "rows" => %{ + "type" => "array", + "items" => %{"type" => "array", "items" => %{"type" => "string"}}, + "description" => "Table rows, each row is an array of cell values" + } } } end @@ -465,10 +762,40 @@ defmodule BDS.AI.ChatTools do %{ "type" => "object", "properties" => %{ - "title" => %{"type" => "string"}, - "chart_type" => %{"type" => "string"}, - "series" => %{"type" => "array"} - } + "chartType" => %{ + "type" => "string", + "enum" => ["bar", "stacked-bar", "line", "area", "pie", "donut", "heatmap"], + "description" => + "The type of chart to render. Use stacked-bar for multi-segment bars. Use heatmap for grid/matrix visualizations." + }, + "title" => %{"type" => "string", "description" => "Optional chart title"}, + "series" => %{ + "type" => "array", + "description" => "Array of data points.", + "items" => %{ + "type" => "object", + "properties" => %{ + "label" => %{"type" => "string", "description" => "Data point label"}, + "value" => %{"type" => "number", "description" => "Data point value"}, + "segments" => %{ + "type" => "array", + "description" => + "Segments within this data point. Required for stacked-bar and heatmap charts.", + "items" => %{ + "type" => "object", + "properties" => %{ + "label" => %{"type" => "string"}, + "value" => %{"type" => "number"} + }, + "required" => ["label", "value"] + } + } + }, + "required" => ["label"] + } + } + }, + "required" => ["chartType", "series"] } end @@ -570,6 +897,79 @@ defmodule BDS.AI.ChatTools do |> Enum.sort_by(&String.downcase(to_string(&1.name))) end + defp metadata_attrs(arguments, keys) do + Enum.reduce(keys, %{}, fn key, acc -> + maybe_put(acc, String.to_atom(key), arguments[key]) + end) + end + + defp media_summary(%Media{} = media) do + %{ + id: media.id, + filename: media.filename, + original_name: media.original_name, + mime_type: media.mime_type, + size: media.size, + width: media.width, + height: media.height, + title: media.title, + alt: media.alt, + caption: media.caption, + author: media.author, + language: media.language, + tags: media.tags || [], + created_at: media.created_at, + updated_at: media.updated_at + } + end + + defp post_media(project_id, post_id) do + Repo.all( + from media in Media, + join: post_media in PostMedia, + on: post_media.media_id == media.id, + where: post_media.project_id == ^project_id and post_media.post_id == ^post_id, + order_by: [asc: post_media.sort_order, asc: media.updated_at] + ) + |> Enum.map(&media_summary/1) + end + + defp media_posts(media_id) do + MediaContext.list_linked_posts(media_id) + |> Enum.map(fn post -> + %{"id" => post.post_id, "title" => post.title, "sort_order" => post.sort_order} + end) + end + + defp thumbnail_data_url(project_id, media, size) do + project = Repo.get!(Project, project_id) + size_key = thumbnail_size(size) + relative_path = MediaContext.thumbnail_paths(media)[size_key] + absolute_path = Path.join(project.data_path, relative_path || "") + + with true <- is_binary(relative_path), + true <- File.exists?(absolute_path), + {:ok, binary} <- File.read(absolute_path) do + "data:#{thumbnail_mime(absolute_path)};base64," <> Base.encode64(binary) + else + _other -> nil + end + end + + defp thumbnail_size("small"), do: :small + defp thumbnail_size("large"), do: :large + defp thumbnail_size(_size), do: :medium + + defp thumbnail_mime(path) do + case Path.extname(path) |> String.downcase() do + ".jpg" -> "image/jpeg" + ".jpeg" -> "image/jpeg" + ".png" -> "image/png" + ".webp" -> "image/webp" + _other -> "application/octet-stream" + end + end + defp blank?(value), do: is_nil(value) or String.trim(to_string(value)) == "" defp normalize_term(value), do: value |> to_string() |> String.downcase() diff --git a/lib/bds/desktop/main_window.ex b/lib/bds/desktop/main_window.ex index 65241c6..ffeaf37 100644 --- a/lib/bds/desktop/main_window.ex +++ b/lib/bds/desktop/main_window.ex @@ -71,6 +71,7 @@ defmodule BDS.Desktop.MainWindow do frame -> apply_restored_bounds(frame) + BDS.Desktop.Shutdown.install_handlers(frame) schedule_persist() {:noreply, diff --git a/lib/bds/desktop/menu.ex b/lib/bds/desktop/menu.ex index 1dfb9e6..94393f3 100644 --- a/lib/bds/desktop/menu.ex +++ b/lib/bds/desktop/menu.ex @@ -2,6 +2,7 @@ defmodule BDS.Desktop.Menu do @moduledoc false use BDS.Desktop.MenuCompat + alias BDS.Desktop.Shutdown alias Desktop.Window @impl true @@ -27,7 +28,7 @@ defmodule BDS.Desktop.Menu do end def handle_event("quit", menu) do - Window.quit() + Shutdown.request_quit() {:noreply, menu} end diff --git a/lib/bds/desktop/menu_bar.ex b/lib/bds/desktop/menu_bar.ex index e4eefd8..8b0b4eb 100644 --- a/lib/bds/desktop/menu_bar.ex +++ b/lib/bds/desktop/menu_bar.ex @@ -2,6 +2,7 @@ defmodule BDS.Desktop.MenuBar do @moduledoc false use BDS.Desktop.MenuCompat + alias BDS.Desktop.Shutdown alias BDS.UI.Commands alias BDS.UI.MenuBar, as: ShellMenuBar alias Desktop.OS @@ -50,7 +51,7 @@ defmodule BDS.Desktop.MenuBar do @impl true def handle_event("quit", menu) do - Window.quit() + Shutdown.request_quit() {:noreply, menu} end diff --git a/lib/bds/desktop/shutdown.ex b/lib/bds/desktop/shutdown.ex new file mode 100644 index 0000000..343bac3 --- /dev/null +++ b/lib/bds/desktop/shutdown.ex @@ -0,0 +1,92 @@ +defmodule BDS.Desktop.Shutdown do + @moduledoc false + + alias BDS.Desktop.MainWindow + alias Desktop.Window + + @stop_delay_ms 100 + + @spec install_handlers(term()) :: :ok + def install_handlers(frame) do + :wx.set_env(Desktop.Env.wx_env()) + + _ = :wxFrame.disconnect(frame, :close_window) + + :wxFrame.connect(frame, :close_window, + callback: &__MODULE__.close_window/2, + userData: self() + ) + + _ = :wxFrame.disconnect(frame, :command_menu_selected, id: Desktop.Wx.wxID_EXIT()) + + :wxFrame.connect(frame, :command_menu_selected, + id: Desktop.Wx.wxID_EXIT(), + callback: &__MODULE__.command_menu_selected/2 + ) + + :ok + rescue + _error -> :ok + catch + :exit, _reason -> :ok + end + + @spec request_quit() :: :ok + def request_quit do + case Application.get_env(:bds, :desktop_shutdown_module, __MODULE__) do + __MODULE__ -> + start_shutdown_task() + + module when is_atom(module) -> + module.request_quit() + end + end + + @spec close_window(tuple(), term()) :: :ok + def close_window(_event, close_event) do + if :wxCloseEvent.canVeto(close_event) do + :wxCloseEvent.veto(close_event) + end + + request_quit() + end + + @spec command_menu_selected(tuple(), term()) :: :ok + def command_menu_selected(_event, _command_event) do + request_quit() + end + + defp start_shutdown_task do + Task.start(fn -> + close_main_window() + Process.sleep(@stop_delay_ms) + System.stop(0) + end) + + :ok + end + + defp close_main_window do + with frame when not is_nil(frame) <- main_frame() do + :wx.set_env(Desktop.Env.wx_env()) + + if :wxWindow.isShown(frame) do + :wxWindow.hide(frame) + end + + :wxWindow.destroy(frame) + else + _other -> :ok + end + rescue + _error -> :ok + catch + :exit, _reason -> :ok + end + + defp main_frame do + Window.frame(MainWindow.window_id()) + catch + :exit, _reason -> nil + end +end diff --git a/priv/ui/app.css b/priv/ui/app.css index 4193daf..a51155f 100644 --- a/priv/ui/app.css +++ b/priv/ui/app.css @@ -5796,11 +5796,15 @@ button svg * { } .chat-input-container { - padding: 16px; + padding: 8px 16px; border-top: 1px solid var(--vscode-editorGroup-border, var(--line, #3c3c3c)); background-color: var(--vscode-sideBar-background, var(--panel-1, #1e1e1e)); } +.chat-panel .chat-input-container { + padding: 8px 16px; +} + .chat-abort-button { display: block; width: 100%; @@ -5818,36 +5822,46 @@ button svg * { background-color: var(--vscode-inputValidation-errorBackground, rgba(244, 135, 113, 0.12)); } -.chat-input-wrapper { +.chat-panel .chat-input-wrapper { + --chat-input-line-height: 20px; + --chat-input-min-height: 20px; + --chat-input-max-height: 160px; display: flex; - align-items: flex-end; - gap: 8px; - padding: 8px; + align-items: center; + gap: 6px; + min-height: 30px; + padding: 4px 6px; border: 1px solid var(--vscode-input-border, var(--line, #3c3c3c)); border-radius: 8px; background-color: var(--vscode-input-background, var(--panel-2, #252526)); } -.chat-input-wrapper:focus-within { +.chat-panel .chat-input-wrapper:focus-within { border-color: var(--vscode-focusBorder, var(--accent-color)); } -.chat-input { +.chat-panel .chat-input { flex: 1; - min-height: 24px; - max-height: 200px; + display: block; + box-sizing: border-box; + height: var(--chat-input-min-height); + min-height: var(--chat-input-min-height); + max-height: var(--chat-input-max-height); + margin: 0; padding: 0; border: none; outline: none; + appearance: none; background: transparent; color: var(--vscode-input-foreground, inherit); font: inherit; - line-height: 1.5; + line-height: var(--chat-input-line-height); resize: none; - overflow-y: auto; + overflow-y: hidden; + vertical-align: top; } -.chat-input::placeholder { +.chat-panel .chat-input::placeholder { color: var(--vscode-input-placeholderForeground, rgba(255, 255, 255, 0.45)); } @@ -5861,10 +5875,16 @@ button svg * { font-size: 12px; } -.chat-send-button { +.chat-panel .chat-send-button { flex-shrink: 0; - width: 32px; - height: 32px; + box-sizing: border-box; + width: 22px; + height: 22px; + min-width: 22px; + min-height: 22px; + max-width: 22px; + max-height: 22px; + padding: 0; display: flex; align-items: center; justify-content: center; @@ -5872,16 +5892,17 @@ button svg * { border-radius: 50%; background-color: var(--vscode-button-background, var(--accent-color)); color: var(--vscode-button-foreground, #ffffff); - font-size: 18px; + font-size: 15px; + line-height: 1; cursor: pointer; transition: background-color 0.15s; } -.chat-send-button:hover:not(:disabled) { +.chat-panel .chat-send-button:hover:not(:disabled) { background-color: var(--vscode-button-hoverBackground, var(--accent-color)); } -.chat-send-button:disabled, +.chat-panel .chat-send-button:disabled, .api-key-submit:disabled { opacity: 0.5; cursor: not-allowed; @@ -6059,6 +6080,10 @@ button svg * { .chat-input-container { padding: 12px; } + + .chat-panel .chat-input-container { + padding: 6px 8px; + } } @media (max-width: 720px) { diff --git a/priv/ui/live.js b/priv/ui/live.js index 2766c83..ffe5109 100644 --- a/priv/ui/live.js +++ b/priv/ui/live.js @@ -731,8 +731,25 @@ document.addEventListener("DOMContentLoaded", () => { return; } - textarea.style.height = "auto"; - textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`; + const styles = getComputedStyle(textarea); + const minHeight = parseFloat(styles.getPropertyValue("--chat-input-min-height")) || 20; + const maxHeight = parseFloat(styles.getPropertyValue("--chat-input-max-height")) || 160; + + textarea.rows = 1; + textarea.style.minHeight = `${minHeight}px`; + + if (textarea.value.trim() === "") { + textarea.style.height = `${minHeight}px`; + textarea.style.maxHeight = `${minHeight}px`; + textarea.style.overflowY = "hidden"; + return; + } + + textarea.style.maxHeight = `${maxHeight}px`; + textarea.style.height = "0px"; + const nextHeight = Math.min(Math.max(textarea.scrollHeight, minHeight), maxHeight); + textarea.style.height = `${nextHeight}px`; + textarea.style.overflowY = nextHeight >= maxHeight ? "auto" : "hidden"; }; this.syncScrollContainer = () => { diff --git a/test/bds/ai_test.exs b/test/bds/ai_test.exs index b170cc2..d8785b9 100644 --- a/test/bds/ai_test.exs +++ b/test/bds/ai_test.exs @@ -482,7 +482,7 @@ defmodule BDS.AITest do test "chat persists user, tool, and assistant messages with usage and blog stats prompt augmentation" do {:ok, project} = create_project_fixture("AI Chat") - :ok = seed_project_content(project.id) + _fixtures = seed_project_content(project.id) assert {:ok, _endpoint} = BDS.AI.put_endpoint( @@ -530,8 +530,15 @@ defmodule BDS.AITest do message["role"] == "system" and String.contains?(message["content"], "Posts: 1") and String.contains?(message["content"], "Media: 1") and String.contains?(message["content"], "Available blog data tools") and + String.contains?(message["content"], "get_blog_stats") and String.contains?(message["content"], "list_posts") and - String.contains?(message["content"], "list_media") + String.contains?(message["content"], "get_media") and + String.contains?(message["content"], "view_image") and + String.contains?(message["content"], "update_post_metadata") and + String.contains?(message["content"], "Available UI Render Tools") and + String.contains?(message["content"], "render_chart") and + String.contains?(message["content"], "heatmap") and + String.contains?(message["content"], "render_tabs") end) tool_descriptions = @@ -540,24 +547,66 @@ defmodule BDS.AITest do {get_in(tool, ["function", "name"]), get_in(tool, ["function", "description"])} end) - assert tool_descriptions["blog_stats"] =~ "aggregate" + expected_old_app_tools = [ + "get_blog_stats", + "search_posts", + "read_post", + "read_post_by_slug", + "list_posts", + "get_media", + "list_media", + "view_image", + "update_post_metadata", + "update_media_metadata", + "list_tags", + "list_categories", + "get_post_backlinks", + "get_post_outlinks", + "get_post_media", + "get_media_posts", + "render_chart", + "render_table", + "render_form", + "render_card", + "render_metric", + "render_list", + "render_tabs", + "render_mindmap" + ] + + assert Enum.all?(expected_old_app_tools, &Map.has_key?(tool_descriptions, &1)) + assert tool_descriptions["get_blog_stats"] =~ "comprehensive blog statistics" assert tool_descriptions["list_posts"] =~ "titles" assert tool_descriptions["list_posts"] =~ "URLs" assert tool_descriptions["list_media"] =~ "filenames" + assert tool_descriptions["render_chart"] =~ "interactive chart" + assert tool_descriptions["render_chart"] =~ "heatmap" + assert tool_descriptions["render_table"] =~ "tabular data" + assert tool_descriptions["render_tabs"] =~ "multiple tabs" + + render_chart_schema = + first_request.tools + |> Enum.find(&(get_in(&1, ["function", "name"]) == "render_chart")) + |> get_in(["function", "parameters", "properties"]) + + assert get_in(render_chart_schema, ["chartType", "enum"]) == [ + "bar", + "stacked-bar", + "line", + "area", + "pie", + "donut", + "heatmap" + ] + + assert get_in(render_chart_schema, ["series", "items", "properties", "segments"]) != nil assert Enum.any?(second_request.messages, fn message -> message["role"] == "tool" end) end test "non-stat chat tools expose concrete project data" do {:ok, project} = create_project_fixture("Concrete Tools") - :ok = seed_project_content(project.id) - - [post] = - Repo.all( - from post in Post, - where: post.project_id == ^project.id, - select: post - ) + %{post: post, media: media} = seed_project_content(project.id) assert %{posts: [listed_post], total: 1} = BDS.AI.ChatTools.execute("list_posts", %{"limit" => 5}, project.id) @@ -567,10 +616,68 @@ defmodule BDS.AITest do assert listed_post["url"] == "/posts/#{post.slug}" assert listed_post["updated_at"] == post.updated_at + assert %{post: read_post} = + BDS.AI.ChatTools.execute("read_post", %{"postId" => post.id}, project.id) + + assert read_post["title"] == post.title + assert read_post["content"] == post.content + assert [listed_media] = BDS.AI.ChatTools.execute("list_media", %{"limit" => 5}, project.id) assert listed_media.filename == "image.png" assert listed_media.mime_type == "image/png" assert listed_media.updated_at + + assert %{media: loaded_media} = + BDS.AI.ChatTools.execute("get_media", %{"mediaId" => media.id}, project.id) + + assert loaded_media.id == media.id + assert loaded_media.title == "Hero" + + assert %{linked_by: []} = + BDS.AI.ChatTools.execute("get_post_backlinks", %{"postId" => post.id}, project.id) + + assert %{links_to: []} = + BDS.AI.ChatTools.execute("get_post_outlinks", %{"postId" => post.id}, project.id) + + assert %{media: []} = + BDS.AI.ChatTools.execute("get_post_media", %{"postId" => post.id}, project.id) + + assert %{posts: []} = + BDS.AI.ChatTools.execute("get_media_posts", %{"mediaId" => media.id}, project.id) + + assert %{success: true, post: updated_post} = + BDS.AI.ChatTools.execute( + "update_post_metadata", + %{"postId" => post.id, "title" => "Updated AI Post"}, + project.id + ) + + assert updated_post["title"] == "Updated AI Post" + + assert %{success: true, media: updated_media} = + BDS.AI.ChatTools.execute( + "update_media_metadata", + %{"mediaId" => media.id, "alt" => "Updated alt"}, + project.id + ) + + assert updated_media.alt == "Updated alt" + + assert %{ + type: "chart", + chart_type: "heatmap", + series: [%{"label" => "2026", "segments" => [%{"label" => "Jan", "value" => 2}]}] + } = + BDS.AI.ChatTools.execute( + "render_chart", + %{ + "chartType" => "heatmap", + "series" => [ + %{"label" => "2026", "segments" => [%{"label" => "Jan", "value" => 2}]} + ] + }, + project.id + ) end test "cancel_chat aborts an in-flight chat turn" do @@ -621,36 +728,39 @@ defmodule BDS.AITest do defp seed_project_content(project_id) do now = Persistence.now_ms() - Repo.insert!( - Post.changeset(%Post{}, %{ - id: Ecto.UUID.generate(), - project_id: project_id, - title: "AI Post", - slug: "ai-post", - excerpt: "Summary", - content: "Body", - status: :draft, - created_at: now, - updated_at: now, - do_not_translate: false - }) - ) + post = + Repo.insert!( + Post.changeset(%Post{}, %{ + id: Ecto.UUID.generate(), + project_id: project_id, + title: "AI Post", + slug: "ai-post", + excerpt: "Summary", + content: "Body", + status: :draft, + created_at: now, + updated_at: now, + do_not_translate: false + }) + ) - Repo.insert!( - Media.changeset(%Media{}, %{ - id: Ecto.UUID.generate(), - project_id: project_id, - filename: "image.png", - original_name: "image.png", - mime_type: "image/png", - size: 128, - file_path: "media/image.png", - sidecar_path: "media/image.png.meta", - created_at: now, - updated_at: now - }) - ) + media = + Repo.insert!( + Media.changeset(%Media{}, %{ + id: Ecto.UUID.generate(), + project_id: project_id, + filename: "image.png", + original_name: "image.png", + mime_type: "image/png", + size: 128, + title: "Hero", + file_path: "media/image.png", + sidecar_path: "media/image.png.meta", + created_at: now, + updated_at: now + }) + ) - :ok + %{post: post, media: media} end end diff --git a/test/bds/desktop/shell_live_test.exs b/test/bds/desktop/shell_live_test.exs index 6533586..dcc55bc 100644 --- a/test/bds/desktop/shell_live_test.exs +++ b/test/bds/desktop/shell_live_test.exs @@ -2247,6 +2247,58 @@ defmodule BDS.Desktop.ShellLiveTest do assert css =~ "line-height: 1.35;" end + test "chat editor keeps empty input single-line until content grows" do + assert {:ok, conversation} = AI.start_chat(%{title: "Input Sizing", model: "gpt-4.1"}) + + {:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive) + + html = + render_click(view, "pin_sidebar_item", %{ + "route" => "chat", + "id" => conversation.id, + "title" => conversation.title, + "subtitle" => conversation.model || "chat" + }) + + assert html =~ ~s(rows="1") + assert html =~ ~s(class="chat-input chat-surface-input") + + css = File.read!(Path.expand("../../../priv/ui/app.css", __DIR__)) + assert css =~ "--chat-input-line-height: 20px;" + assert css =~ "--chat-input-min-height: 20px;" + assert css =~ ".chat-panel .chat-input-container" + assert css =~ "padding: 8px 16px;" + assert css =~ "padding: 6px 8px;" + assert css =~ ".chat-panel .chat-input-wrapper" + assert css =~ "min-height: 30px;" + assert css =~ "padding: 4px 6px;" + assert css =~ ".chat-panel .chat-input" + assert css =~ "box-sizing: border-box;" + assert css =~ "margin: 0;" + assert css =~ "height: var(--chat-input-min-height);" + assert css =~ "min-height: var(--chat-input-min-height);" + assert css =~ "overflow-y: hidden;" + assert css =~ ".chat-panel .chat-send-button" + assert css =~ "width: 22px;" + assert css =~ "height: 22px;" + assert css =~ "max-width: 22px;" + assert css =~ "max-height: 22px;" + assert css =~ "padding: 0;" + + live_js = File.read!(Path.expand("../../../priv/ui/live.js", __DIR__)) + + assert live_js =~ + "minHeight = parseFloat(styles.getPropertyValue(\"--chat-input-min-height\"))" + + assert live_js =~ "textarea.value.trim() === \"\"" + assert live_js =~ "textarea.rows = 1;" + assert live_js =~ "textarea.style.minHeight = `${minHeight}px`;" + assert live_js =~ "textarea.style.height = `${minHeight}px`;" + assert live_js =~ "textarea.style.maxHeight = `${minHeight}px`;" + assert live_js =~ "textarea.style.height = \"0px\";" + assert live_js =~ "textarea.style.overflowY = nextHeight >= maxHeight ? \"auto\" : \"hidden\"" + end + test "chat editor groups selector models by provider and uses catalog labels" do updated_at = Persistence.now_ms() diff --git a/test/bds/desktop_test.exs b/test/bds/desktop_test.exs index 46bef29..3bd3239 100644 --- a/test/bds/desktop_test.exs +++ b/test/bds/desktop_test.exs @@ -3,6 +3,13 @@ defmodule BDS.DesktopTest do import Plug.Test + defmodule FakeShutdown do + def request_quit do + send(Application.fetch_env!(:bds, :desktop_shutdown_test_pid), :quit_requested) + :ok + end + end + test "desktop configuration no longer uses a pending adapter" do assert Application.get_env(:bds, BDS.Application)[:desktop_adapter] == :desktop end @@ -99,6 +106,38 @@ defmodule BDS.DesktopTest do assert menu_item(groups, :metadata_diff).shortcut == nil end + test "native menu quit requests app-owned shutdown" do + previous_module = Application.get_env(:bds, :desktop_shutdown_module) + previous_pid = Application.get_env(:bds, :desktop_shutdown_test_pid) + + Application.put_env(:bds, :desktop_shutdown_module, FakeShutdown) + Application.put_env(:bds, :desktop_shutdown_test_pid, self()) + + on_exit(fn -> + restore_env(:desktop_shutdown_module, previous_module) + restore_env(:desktop_shutdown_test_pid, previous_pid) + end) + + assert {:noreply, %{}} = BDS.Desktop.MenuBar.handle_event("quit", %{}) + assert_receive :quit_requested + end + + test "icon menu quit requests app-owned shutdown" do + previous_module = Application.get_env(:bds, :desktop_shutdown_module) + previous_pid = Application.get_env(:bds, :desktop_shutdown_test_pid) + + Application.put_env(:bds, :desktop_shutdown_module, FakeShutdown) + Application.put_env(:bds, :desktop_shutdown_test_pid, self()) + + on_exit(fn -> + restore_env(:desktop_shutdown_module, previous_module) + restore_env(:desktop_shutdown_test_pid, previous_pid) + end) + + assert {:noreply, %{}} = BDS.Desktop.Menu.handle_event("quit", %{}) + assert_receive :quit_requested + end + test "desktop root html is a LiveView shell and references only the live bootstrap assets" do conn = conn(:get, "/?k=#{Desktop.Auth.login_key()}") conn = BDS.Desktop.Endpoint.call(conn, BDS.Desktop.Endpoint.init([])) @@ -178,4 +217,7 @@ defmodule BDS.DesktopTest do Image.new!(3, 2, color: [255, 0, 0]) |> Image.write!(:memory, suffix: ".jpg", quality: 85) end + + defp restore_env(key, nil), do: Application.delete_env(:bds, key) + defp restore_env(key, value), do: Application.put_env(:bds, key, value) end