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">×</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