add extension points for chat, sidebar, and modal shell

- Introduced new extension points in various chat components: `chat-input`, `chat-top`, and `chat-bar`. - Added extension points for sidebar components: `sidebar-start`, `sidebar-end`, and others. - Updated modal structure with extension points for better integration. - Updated documentation in README.md to reflect current sidebar, input, chat, welcome, and modal surfaces. - Added tests for web UI extension surfaces to ensure proper integration and functionality.

Alessandro committed Feb 17, 2026 at 18:23 UTC d65fada385f941b9a768f735d57663dca3fbdd52
14 files changed +207 -6
plugins/README.md
+57
@@ -35,6 +35,53 @@ Core UI defines insertion points like:
35 <x-extension id="sidebar-quick-actions-main-start"></x-extension>
36 ```
37
38 +Current sidebar surfaces:
39 +
40 +- `sidebar-start`
41 +- `sidebar-end`
42 +- `sidebar-top-wrapper-start`
43 +- `sidebar-top-wrapper-end`
44 +- `sidebar-quick-actions-main-start`
45 +- `sidebar-quick-actions-main-end`
46 +- `sidebar-quick-actions-dropdown-start`
47 +- `sidebar-quick-actions-dropdown-end`
48 +- `sidebar-chats-list-start`
49 +- `sidebar-chats-list-end`
50 +- `sidebar-tasks-list-start`
51 +- `sidebar-tasks-list-end`
52 +- `sidebar-bottom-wrapper-start`
53 +- `sidebar-bottom-wrapper-end`
54 +
55 +Current input surfaces:
56 +
57 +- `chat-input-start`
58 +- `chat-input-end`
59 +- `chat-input-progress-start`
60 +- `chat-input-progress-end`
61 +- `chat-input-box-start`
62 +- `chat-input-box-end`
63 +- `chat-input-bottom-actions-start`
64 +- `chat-input-bottom-actions-end`
65 +
66 +Current chat surfaces:
67 +
68 +- `chat-top-start`
69 +- `chat-top-end`
70 +
71 +Current welcome surfaces:
72 +
73 +- `welcome-screen-start`
74 +- `welcome-screen-end`
75 +
76 +Current modal surfaces:
77 +
78 +- `modal-shell-start`
79 +- `modal-shell-end`
80 +
81 +Placement pattern:
82 +- keep wrapper-level anchors in parent composition files
83 +- keep section anchors in their owning component files, inside local `x-data` scope
84 +
85 Resolution flow:
86
87 1. `webui/js/extensions.js` finds `x-extension` nodes.
@@ -72,6 +119,10 @@ Runtime code calls:
119
120 `callJsExtensions("<extension_point>", ...args)`
121
122 +JS hook convention:
123 +- pass one mutable context object when extensions are expected to influence behavior
124 +- that object is passed by reference, so mutations are visible to subsequent hooks in the same flow
125 +
126 Example:
127
128 `set_messages_before_loop` and `set_messages_after_loop` in `webui/js/messages.js`.
@@ -86,6 +137,12 @@ Example:
137 - `x-move-before`
138 - `x-move-after`
139
140 +Placement behavior:
141 +- `x-move-to-start`, `x-move-to-end`, and `x-move-to` resolve a parent selector and insert the extension element as that parent's child.
142 +- `x-move-before` and `x-move-after` resolve a reference selector and insert the extension element as a sibling in the reference element's parent.
143 +- This structural difference can produce different visual results when parent and sibling styling differ (for example dropdown spacing/padding).
144 +- Example anchor selector for placing after the first dropdown item: `x-move-after=".quick-actions-dropdown .dropdown-header + .dropdown-item"`.
145 +
146 ## Plugin Author Flow
147
148 1. Pick an existing core breakpoint ID (`<x-extension id="...">`).
plugins/tests/test_webui_extension_surfaces.py new
+118
@@ -0,0 +1,118 @@
1 +from __future__ import annotations
2 +
3 +import sys
4 +import tempfile
5 +import threading
6 +from contextlib import contextmanager
7 +from pathlib import Path
8 +from typing import Iterator
9 +
10 +import pytest
11 +from flask import Flask
12 +
13 +PROJECT_ROOT = Path(__file__).resolve().parents[2]
14 +if str(PROJECT_ROOT) not in sys.path:
15 + sys.path.insert(0, str(PROJECT_ROOT))
16 +
17 +from python.api.load_webui_extensions import LoadWebuiExtensions
18 +
19 +
20 +SURFACE_SCENARIOS: list[tuple[str, str]] = [
21 + ("sidebar-start", "webui/components/sidebar/left-sidebar.html"),
22 + ("sidebar-end", "webui/components/sidebar/left-sidebar.html"),
23 + ("sidebar-top-wrapper-start", "webui/components/sidebar/top-section/sidebar-top.html"),
24 + ("sidebar-top-wrapper-end", "webui/components/sidebar/top-section/sidebar-top.html"),
25 + ("sidebar-quick-actions-main-start", "webui/components/sidebar/top-section/quick-actions.html"),
26 + ("sidebar-quick-actions-main-end", "webui/components/sidebar/top-section/quick-actions.html"),
27 + ("sidebar-quick-actions-dropdown-start", "webui/components/sidebar/top-section/quick-actions.html"),
28 + ("sidebar-quick-actions-dropdown-end", "webui/components/sidebar/top-section/quick-actions.html"),
29 + ("sidebar-chats-list-start", "webui/components/sidebar/chats/chats-list.html"),
30 + ("sidebar-chats-list-end", "webui/components/sidebar/chats/chats-list.html"),
31 + ("sidebar-tasks-list-start", "webui/components/sidebar/tasks/tasks-list.html"),
32 + ("sidebar-tasks-list-end", "webui/components/sidebar/tasks/tasks-list.html"),
33 + ("sidebar-bottom-wrapper-start", "webui/components/sidebar/bottom/sidebar-bottom.html"),
34 + ("sidebar-bottom-wrapper-end", "webui/components/sidebar/bottom/sidebar-bottom.html"),
35 + ("chat-input-start", "webui/components/chat/input/chat-bar.html"),
36 + ("chat-input-end", "webui/components/chat/input/chat-bar.html"),
37 + ("chat-input-progress-start", "webui/components/chat/input/progress.html"),
38 + ("chat-input-progress-end", "webui/components/chat/input/progress.html"),
39 + ("chat-input-box-start", "webui/components/chat/input/chat-bar-input.html"),
40 + ("chat-input-box-end", "webui/components/chat/input/chat-bar-input.html"),
41 + ("chat-input-bottom-actions-start", "webui/components/chat/input/bottom-actions.html"),
42 + ("chat-input-bottom-actions-end", "webui/components/chat/input/bottom-actions.html"),
43 + ("chat-top-start", "webui/components/chat/top-section/chat-top.html"),
44 + ("chat-top-end", "webui/components/chat/top-section/chat-top.html"),
45 + ("welcome-screen-start", "webui/components/welcome/welcome-screen.html"),
46 + ("welcome-screen-end", "webui/components/welcome/welcome-screen.html"),
47 + ("modal-shell-start", "webui/js/modals.js"),
48 + ("modal-shell-end", "webui/js/modals.js"),
49 +]
50 +
51 +
52 +def _new_handler() -> LoadWebuiExtensions:
53 + app = Flask("test_webui_extension_surfaces")
54 + app.secret_key = "test-secret"
55 + return LoadWebuiExtensions(app, threading.RLock())
56 +
57 +
58 +def _assert_surface_anchor_in_template(surface: str, template_rel_path: str) -> None:
59 + template_path = PROJECT_ROOT / template_rel_path
60 + template_html = template_path.read_text(encoding="utf-8")
61 + assert f'<x-extension id="{surface}"></x-extension>' in template_html
62 +
63 +
64 +@contextmanager
65 +def _temporary_probe_plugin(surface: str) -> Iterator[tuple[str, str]]:
66 + plugins_root = PROJECT_ROOT / "plugins"
67 + with tempfile.TemporaryDirectory(
68 + prefix="tmp_surface_probe_",
69 + dir=plugins_root,
70 + ) as temp_plugin_dir:
71 + plugin_id = Path(temp_plugin_dir).name
72 + probe_file = (
73 + Path(temp_plugin_dir)
74 + / "extensions"
75 + / "webui"
76 + / surface
77 + / "surface-probe.html"
78 + )
79 + probe_file.parent.mkdir(parents=True, exist_ok=True)
80 + probe_file.write_text(
81 + (
82 + "<div x-data "
83 + f'data-surface-probe="{surface}" '
84 + f'data-plugin-id="{plugin_id}"></div>'
85 + ),
86 + encoding="utf-8",
87 + )
88 + yield plugin_id, probe_file.name
89 +
90 +
91 +@pytest.mark.asyncio
92 +@pytest.mark.parametrize(
93 + ("surface", "template_rel_path"),
94 + SURFACE_SCENARIOS,
95 + ids=[scenario[0] for scenario in SURFACE_SCENARIOS],
96 +)
97 +async def test_webui_surface_extension_point_end_to_end(
98 + surface: str,
99 + template_rel_path: str,
100 +) -> None:
101 + _assert_surface_anchor_in_template(surface, template_rel_path)
102 +
103 + with _temporary_probe_plugin(surface) as (plugin_id, probe_file_name):
104 + payload = await _new_handler().process(
105 + {"extension_point": surface, "filters": ["*.html"]},
106 + None,
107 + )
108 + assert isinstance(payload, dict)
109 + extensions = payload.get("extensions", [])
110 + expected_suffix = (
111 + f"{plugin_id}/extensions/webui/{surface}/{probe_file_name}"
112 + )
113 +
114 + assert any(
115 + extension.get("plugin_id") == plugin_id
116 + and str(extension.get("path", "")).replace("\\", "/").endswith(expected_suffix)
117 + for extension in extensions
118 + )
webui/components/chat/input/bottom-actions.html
+2
@@ -10,6 +10,7 @@
10 <body>
11 <div x-data>
12 <template x-if="$store.chatInput">
13 + <x-extension id="chat-input-bottom-actions-start"></x-extension>
14 <div class="text-buttons-row">
15 <button class="text-button" @click="$store.chatInput.pauseAgent(!$store.chatInput.paused)">
16 <template x-if="!$store.chatInput.paused">
@@ -74,6 +75,7 @@
75 <p>Nudge</p>
76 </button>
77 </div>
78 + <x-extension id="chat-input-bottom-actions-end"></x-extension>
79 </template>
80 </div>
81
webui/components/chat/input/chat-bar-input.html
+8 -4
@@ -9,7 +9,9 @@
9 </script>
10 </head>
11 <body>
12 - <div class="input-row">
12 + <div x-data>
13 + <x-extension id="chat-input-box-start"></x-extension>
14 + <div class="input-row">
15 <!-- Attachment icon with tooltip -->
16 <div class="attachment-wrapper">
17 <label for="file-input" class="attachment-icon" title="Add attachments to the message" data-bs-placement="top" data-bs-trigger="hover">
@@ -50,10 +52,12 @@
52 </svg>
53 </button>
54 </div>
53 - </div>
55 + </div>
56
55 - <!-- Full Screen Input Modal -->
56 - <x-component path="modals/full-screen-input/full-screen-input.html"></x-component>
57 + <!-- Full Screen Input Modal -->
58 + <x-component path="modals/full-screen-input/full-screen-input.html"></x-component>
59 + <x-extension id="chat-input-box-end"></x-extension>
60 + </div>
61
62
63 <style>
webui/components/chat/input/chat-bar.html
+2
@@ -7,6 +7,7 @@
7 </head>
8 <body>
9 <div id="input-section" x-data>
10 + <x-extension id="chat-input-start"></x-extension>
11 <template x-if="$store.chatInput">
12 <div style="width: 100%; display: contents;">
13 <!-- Message Queue section -->
@@ -21,6 +22,7 @@
22 <x-component path="chat/input/bottom-actions.html"></x-component>
23 </div>
24 </template>
25 + <x-extension id="chat-input-end"></x-extension>
26 </div>
27
28
webui/components/chat/input/progress.html
+3 -1
@@ -6,7 +6,8 @@
6 </script>
7 </head>
8 <body>
9 - <div id="progress-bar-box">
9 + <div id="progress-bar-box" x-data>
10 + <x-extension id="chat-input-progress-start"></x-extension>
11 <h4 id="progress-bar-h">
12 <span id="progress-bar-i">|></span><span id="progress-bar"></span>
13 </h4>
@@ -31,6 +32,7 @@
32 </button>
33 </div>
34 </div>
35 + <x-extension id="chat-input-progress-end"></x-extension>
36 </div>
37
38
webui/components/chat/top-section/chat-top.html
+2
@@ -14,6 +14,7 @@
14 <!-- This construct of x-data + x-if is used to ensure the component is only rendered when the store is available -->
15 <div x-data>
16 <template x-if="$store.chatTop">
17 + <x-extension id="chat-top-start"></x-extension>
18
19 <!-- Time and Date -->
20 <div id="time-date-container">
@@ -24,6 +25,7 @@
25 <!-- Project Selector -->
26 <x-component path="projects/project-selector.html"></x-component>
27 </div>
28 + <x-extension id="chat-top-end"></x-extension>
29
30 </template>
31 </div>
webui/components/sidebar/bottom/sidebar-bottom.html
+2
@@ -8,6 +8,7 @@
8 <div x-data>
9 <template x-if="$store.sidebarBottom">
10 <div class="sidebar-bottom-wrapper">
11 + <x-extension id="sidebar-bottom-wrapper-start"></x-extension>
12 <!-- Preferences Panel -->
13 <x-component path="sidebar/bottom/preferences/preferences-panel.html"></x-component>
14
@@ -15,6 +16,7 @@
16 <div class="version-info">
17 <span id="a0version" x-text="$store.sidebarBottom.versionLabel"></span>
18 </div>
19 + <x-extension id="sidebar-bottom-wrapper-end"></x-extension>
20 </div>
21 </template>
22 </div>
webui/components/sidebar/chats/chats-list.html
+2
@@ -10,6 +10,7 @@
10 <div x-data>
11 <template x-if="$store.chats">
12 <div class="chats-list-container" x-data>
13 + <x-extension id="sidebar-chats-list-start"></x-extension>
14 <div class="section-header-row">
15 <h3 class="section-header">Chats</h3>
16 <!-- New Chat Button -->
@@ -41,6 +42,7 @@
42 <div class="empty-list-message" x-show="$store.chats.contexts.length === 0">
43 <p><i>No chats to list.</i></p>
44 </div>
45 + <x-extension id="sidebar-chats-list-end"></x-extension>
46 </div>
47 </template>
48 </div>
webui/components/sidebar/left-sidebar.html
+2
@@ -11,6 +11,7 @@
11 <div x-data>
12 <template x-if="$store.sidebar">
13 <div id="left-panel" class="panel" x-data :class="{'hidden': !$store.sidebar.isOpen}">
14 + <x-extension id="sidebar-start"></x-extension>
15 <!--Sidebar upper elements-->
16 <div class="left-panel-top no-scrollbar">
17 <!-- Top Section: Header Icons + Quick Actions -->
@@ -29,6 +30,7 @@
30 <div class="left-panel-bottom">
31 <x-component path="sidebar/bottom/sidebar-bottom.html"></x-component>
32 </div>
33 + <x-extension id="sidebar-end"></x-extension>
34 </div>
35 </template>
36 </div>
webui/components/sidebar/tasks/tasks-list.html
+2
@@ -8,6 +8,7 @@
8 <div class="tasks-list-root">
9 <template x-if="$store.tasks">
10 <div x-data class="tasks-list-inner">
11 + <x-extension id="sidebar-tasks-list-start"></x-extension>
12 <h3 class="section-header section-header-collapsible"
13 data-bs-toggle="collapse"
14 @click="$store.sidebar.toggleSection('tasks')"
@@ -64,6 +65,7 @@
65 </div>
66 </div>
67 </div>
68 + <x-extension id="sidebar-tasks-list-end"></x-extension>
69 </div>
70 </template>
71 </div>
webui/components/sidebar/top-section/sidebar-top.html
+2
@@ -8,11 +8,13 @@
8 <div x-data>
9 <template x-if="$store.sidebar">
10 <div class="sidebar-top-wrapper">
11 + <x-extension id="sidebar-top-wrapper-start"></x-extension>
12 <!-- Header Icons (Toggle + Logo) -->
13 <x-component path="sidebar/top-section/header-icons.html"></x-component>
14
15 <!-- Quick Actions Buttons -->
16 <x-component path="sidebar/top-section/quick-actions.html"></x-component>
17 + <x-extension id="sidebar-top-wrapper-end"></x-extension>
18 </div>
19 </template>
20 </div>
webui/components/welcome/welcome-screen.html
+2
@@ -9,6 +9,7 @@
9 <body>
10 <div x-data >
11 <template x-if="$store.welcomeStore && $store.welcomeStore.isVisible">
12 + <x-extension id="welcome-screen-start"></x-extension>
13 <div x-create="$store.welcomeStore.onCreate()" class="welcome-container">
14 <div class="welcome-content">
15 <!-- Action Cards -->
@@ -96,6 +97,7 @@
97 </div>
98 </div>
99 </div>
100 + <x-extension id="welcome-screen-end"></x-extension>
101 </template>
102 </div>
103
webui/js/modals.js
+3 -1
@@ -63,7 +63,8 @@ function createModalElement(path) {
63
64 // Create modal structure
65 newModal.innerHTML = `
66 - <div class="modal-inner">
66 + <div class="modal-inner" x-data>
67 + <x-extension id="modal-shell-start"></x-extension>
68 <div class="modal-header">
69 <h2 class="modal-title"></h2>
70 <button class="modal-close">&times;</button>
@@ -72,6 +73,7 @@ function createModalElement(path) {
73 <div class="modal-bd"></div>
74 </div>
75 <div class="modal-footer-slot" style="display: none;"></div>
76 + <x-extension id="modal-shell-end"></x-extension>
77 </div>
78 `;
79