Allow to regenerate a summary of the assistant context (#14964)
Both manual and LLM-through ways are supported: https://github.com/user-attachments/assets/afb0d2b3-9a9b-4f78-a909-1e663e686323 Release Notes: - Improved assistant panel summarization usability
This commit is contained in:
parent
a0d687c24a
commit
a5cb66f0e1
4 changed files with 204 additions and 100 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
@ -375,7 +375,6 @@ dependencies = [
|
|||
"assets",
|
||||
"assistant_slash_command",
|
||||
"async-watch",
|
||||
"breadcrumbs",
|
||||
"cargo_toml",
|
||||
"chrono",
|
||||
"client",
|
||||
|
|
|
@ -26,7 +26,6 @@ anyhow.workspace = true
|
|||
assets.workspace = true
|
||||
assistant_slash_command.workspace = true
|
||||
async-watch.workspace = true
|
||||
breadcrumbs.workspace = true
|
||||
cargo_toml.workspace = true
|
||||
chrono.workspace = true
|
||||
client.workspace = true
|
||||
|
|
|
@ -16,7 +16,6 @@ use crate::{
|
|||
};
|
||||
use anyhow::{anyhow, Result};
|
||||
use assistant_slash_command::{SlashCommand, SlashCommandOutputSection};
|
||||
use breadcrumbs::Breadcrumbs;
|
||||
use client::proto;
|
||||
use collections::{BTreeSet, HashMap, HashSet};
|
||||
use completion::CompletionProvider;
|
||||
|
@ -50,6 +49,7 @@ use project::{Project, ProjectLspAdapterDelegate};
|
|||
use search::{buffer_search::DivRegistrar, BufferSearchBar};
|
||||
use settings::Settings;
|
||||
use std::{
|
||||
borrow::Cow,
|
||||
cmp::{self, Ordering},
|
||||
fmt::Write,
|
||||
ops::Range,
|
||||
|
@ -58,7 +58,6 @@ use std::{
|
|||
time::Duration,
|
||||
};
|
||||
use terminal_view::{terminal_panel::TerminalPanel, TerminalView};
|
||||
use theme::ThemeSettings;
|
||||
use ui::{
|
||||
prelude::*,
|
||||
utils::{format_distance_from_now, DateTimeType},
|
||||
|
@ -68,7 +67,7 @@ use ui::{
|
|||
use util::ResultExt;
|
||||
use workspace::{
|
||||
dock::{DockPosition, Panel, PanelEvent},
|
||||
item::{self, BreadcrumbText, FollowableItem, Item, ItemHandle},
|
||||
item::{self, FollowableItem, Item, ItemHandle},
|
||||
notifications::NotifyTaskExt,
|
||||
pane::{self, SaveIntent},
|
||||
searchable::{SearchEvent, SearchableItem},
|
||||
|
@ -113,6 +112,7 @@ pub struct AssistantPanel {
|
|||
subscriptions: Vec<Subscription>,
|
||||
authentication_prompt: Option<AnyView>,
|
||||
model_selector_menu_handle: PopoverMenuHandle<ContextMenu>,
|
||||
model_summary_editor: View<Editor>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
|
@ -300,6 +300,14 @@ impl AssistantPanel {
|
|||
cx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
let model_selector_menu_handle = PopoverMenuHandle::default();
|
||||
let model_summary_editor = cx.new_view(|cx| Editor::single_line(cx));
|
||||
let context_editor_toolbar = cx.new_view(|_| {
|
||||
ContextEditorToolbarItem::new(
|
||||
workspace,
|
||||
model_selector_menu_handle.clone(),
|
||||
model_summary_editor.clone(),
|
||||
)
|
||||
});
|
||||
let pane = cx.new_view(|cx| {
|
||||
let mut pane = Pane::new(
|
||||
workspace.weak_handle(),
|
||||
|
@ -345,13 +353,7 @@ impl AssistantPanel {
|
|||
.into_any_element()
|
||||
});
|
||||
pane.toolbar().update(cx, |toolbar, cx| {
|
||||
toolbar.add_item(cx.new_view(|_| Breadcrumbs::new()), cx);
|
||||
toolbar.add_item(
|
||||
cx.new_view(|_| {
|
||||
ContextEditorToolbarItem::new(workspace, model_selector_menu_handle.clone())
|
||||
}),
|
||||
cx,
|
||||
);
|
||||
toolbar.add_item(context_editor_toolbar.clone(), cx);
|
||||
toolbar.add_item(cx.new_view(BufferSearchBar::new), cx)
|
||||
});
|
||||
pane
|
||||
|
@ -360,6 +362,8 @@ impl AssistantPanel {
|
|||
let subscriptions = vec![
|
||||
cx.observe(&pane, |_, _, cx| cx.notify()),
|
||||
cx.subscribe(&pane, Self::handle_pane_event),
|
||||
cx.subscribe(&context_editor_toolbar, Self::handle_toolbar_event),
|
||||
cx.subscribe(&model_summary_editor, Self::handle_summary_editor_event),
|
||||
cx.observe_global::<CompletionProvider>({
|
||||
let mut prev_settings_version = CompletionProvider::global(cx).settings_version();
|
||||
move |this, cx| {
|
||||
|
@ -381,6 +385,7 @@ impl AssistantPanel {
|
|||
subscriptions,
|
||||
authentication_prompt: None,
|
||||
model_selector_menu_handle,
|
||||
model_summary_editor,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -390,10 +395,19 @@ impl AssistantPanel {
|
|||
event: &pane::Event,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) {
|
||||
match event {
|
||||
pane::Event::Remove => cx.emit(PanelEvent::Close),
|
||||
pane::Event::ZoomIn => cx.emit(PanelEvent::ZoomIn),
|
||||
pane::Event::ZoomOut => cx.emit(PanelEvent::ZoomOut),
|
||||
let update_model_summary = match event {
|
||||
pane::Event::Remove => {
|
||||
cx.emit(PanelEvent::Close);
|
||||
false
|
||||
}
|
||||
pane::Event::ZoomIn => {
|
||||
cx.emit(PanelEvent::ZoomIn);
|
||||
false
|
||||
}
|
||||
pane::Event::ZoomOut => {
|
||||
cx.emit(PanelEvent::ZoomOut);
|
||||
false
|
||||
}
|
||||
|
||||
pane::Event::AddItem { item } => {
|
||||
self.workspace
|
||||
|
@ -401,6 +415,7 @@ impl AssistantPanel {
|
|||
item.added_to_pane(workspace, self.pane.clone(), cx)
|
||||
})
|
||||
.ok();
|
||||
true
|
||||
}
|
||||
|
||||
pane::Event::ActivateItem { local } => {
|
||||
|
@ -412,13 +427,59 @@ impl AssistantPanel {
|
|||
.ok();
|
||||
}
|
||||
cx.emit(AssistantPanelEvent::ContextEdited);
|
||||
true
|
||||
}
|
||||
|
||||
pane::Event::RemoveItem { .. } => {
|
||||
cx.emit(AssistantPanelEvent::ContextEdited);
|
||||
true
|
||||
}
|
||||
|
||||
_ => {}
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if update_model_summary {
|
||||
if let Some(editor) = self.active_context_editor(cx) {
|
||||
self.show_updated_summary(&editor, cx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_summary_editor_event(
|
||||
&mut self,
|
||||
model_summary_editor: View<Editor>,
|
||||
event: &EditorEvent,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) {
|
||||
if matches!(event, EditorEvent::Edited { .. }) {
|
||||
if let Some(context_editor) = self.active_context_editor(cx) {
|
||||
let new_summary = model_summary_editor.read(cx).text(cx);
|
||||
context_editor.update(cx, |context_editor, cx| {
|
||||
context_editor.context.update(cx, |context, cx| {
|
||||
if context.summary().is_none()
|
||||
&& (new_summary == DEFAULT_TAB_TITLE || new_summary.trim().is_empty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
context.custom_summary(new_summary, cx)
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_toolbar_event(
|
||||
&mut self,
|
||||
_: View<ContextEditorToolbarItem>,
|
||||
_: &ContextEditorToolbarItemEvent,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) {
|
||||
if let Some(context_editor) = self.active_context_editor(cx) {
|
||||
context_editor.update(cx, |context_editor, cx| {
|
||||
context_editor.context.update(cx, |context, cx| {
|
||||
context.summarize(true, cx);
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -430,17 +491,19 @@ impl AssistantPanel {
|
|||
if self.is_authenticated(cx) {
|
||||
self.authentication_prompt = None;
|
||||
|
||||
if let Some(editor) = self.active_context_editor(cx) {
|
||||
match self.active_context_editor(cx) {
|
||||
Some(editor) => {
|
||||
editor.update(cx, |active_context, cx| {
|
||||
active_context
|
||||
.context
|
||||
.update(cx, |context, cx| context.completion_provider_changed(cx))
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
if self.active_context_editor(cx).is_none() {
|
||||
None => {
|
||||
self.new_context(cx);
|
||||
}
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
} else if self.authentication_prompt.is_none()
|
||||
|| prev_settings_version != CompletionProvider::global(cx).settings_version()
|
||||
|
@ -637,18 +700,43 @@ impl AssistantPanel {
|
|||
.push(cx.subscribe(&context_editor, Self::handle_context_editor_event));
|
||||
}
|
||||
|
||||
self.show_updated_summary(&context_editor, cx);
|
||||
|
||||
cx.emit(AssistantPanelEvent::ContextEdited);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn show_updated_summary(
|
||||
&self,
|
||||
context_editor: &View<ContextEditor>,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) {
|
||||
context_editor.update(cx, |context_editor, cx| {
|
||||
let new_summary = context_editor
|
||||
.context
|
||||
.read(cx)
|
||||
.summary()
|
||||
.map(|s| s.text.clone())
|
||||
.unwrap_or_else(|| context_editor.title(cx).to_string());
|
||||
self.model_summary_editor.update(cx, |summary_editor, cx| {
|
||||
if summary_editor.text(cx) != new_summary {
|
||||
summary_editor.set_text(new_summary, cx);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn handle_context_editor_event(
|
||||
&mut self,
|
||||
_: View<ContextEditor>,
|
||||
context_editor: View<ContextEditor>,
|
||||
event: &EditorEvent,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) {
|
||||
match event {
|
||||
EditorEvent::TitleChanged { .. } => cx.notify(),
|
||||
EditorEvent::TitleChanged => {
|
||||
self.show_updated_summary(&context_editor, cx);
|
||||
cx.notify()
|
||||
}
|
||||
EditorEvent::Edited { .. } => cx.emit(AssistantPanelEvent::ContextEdited),
|
||||
_ => {}
|
||||
}
|
||||
|
@ -1001,9 +1089,10 @@ pub struct ContextEditor {
|
|||
assistant_panel: WeakView<AssistantPanel>,
|
||||
}
|
||||
|
||||
impl ContextEditor {
|
||||
const DEFAULT_TAB_TITLE: &str = "New Context";
|
||||
const MAX_TAB_TITLE_LEN: usize = 16;
|
||||
|
||||
impl ContextEditor {
|
||||
fn for_context(
|
||||
context: Model<Context>,
|
||||
fs: Arc<dyn Fs>,
|
||||
|
@ -1316,7 +1405,7 @@ impl ContextEditor {
|
|||
ContextEvent::SummaryChanged => {
|
||||
cx.emit(EditorEvent::TitleChanged);
|
||||
self.context.update(cx, |context, cx| {
|
||||
context.save(None, self.fs.clone(), cx);
|
||||
context.save(Some(Duration::from_millis(500)), self.fs.clone(), cx);
|
||||
});
|
||||
}
|
||||
ContextEvent::StreamedCompletion => {
|
||||
|
@ -2031,16 +2120,18 @@ impl ContextEditor {
|
|||
}
|
||||
|
||||
fn save(&mut self, _: &Save, cx: &mut ViewContext<Self>) {
|
||||
self.context
|
||||
.update(cx, |context, cx| context.save(None, self.fs.clone(), cx));
|
||||
self.context.update(cx, |context, cx| {
|
||||
context.save(Some(Duration::from_millis(500)), self.fs.clone(), cx)
|
||||
});
|
||||
}
|
||||
|
||||
fn title(&self, cx: &AppContext) -> String {
|
||||
fn title(&self, cx: &AppContext) -> Cow<str> {
|
||||
self.context
|
||||
.read(cx)
|
||||
.summary()
|
||||
.map(|summary| summary.text.clone())
|
||||
.unwrap_or_else(|| "New Context".into())
|
||||
.map(Cow::Owned)
|
||||
.unwrap_or_else(|| Cow::Borrowed(DEFAULT_TAB_TITLE))
|
||||
}
|
||||
|
||||
fn render_send_button(&self, cx: &mut ViewContext<Self>) -> impl IntoElement {
|
||||
|
@ -2139,14 +2230,13 @@ impl Item for ContextEditor {
|
|||
type Event = editor::EditorEvent;
|
||||
|
||||
fn tab_content_text(&self, cx: &WindowContext) -> Option<SharedString> {
|
||||
Some(util::truncate_and_trailoff(&self.title(cx), Self::MAX_TAB_TITLE_LEN).into())
|
||||
Some(util::truncate_and_trailoff(&self.title(cx), MAX_TAB_TITLE_LEN).into())
|
||||
}
|
||||
|
||||
fn to_item_events(event: &Self::Event, mut f: impl FnMut(item::ItemEvent)) {
|
||||
match event {
|
||||
EditorEvent::Edited { .. } => {
|
||||
f(item::ItemEvent::Edit);
|
||||
f(item::ItemEvent::UpdateBreadcrumbs);
|
||||
}
|
||||
EditorEvent::TitleChanged => {
|
||||
f(item::ItemEvent::UpdateTab);
|
||||
|
@ -2156,48 +2246,13 @@ impl Item for ContextEditor {
|
|||
}
|
||||
|
||||
fn tab_tooltip_text(&self, cx: &AppContext) -> Option<SharedString> {
|
||||
Some(self.title(cx).into())
|
||||
Some(self.title(cx).to_string().into())
|
||||
}
|
||||
|
||||
fn as_searchable(&self, handle: &View<Self>) -> Option<Box<dyn SearchableItemHandle>> {
|
||||
Some(Box::new(handle.clone()))
|
||||
}
|
||||
|
||||
fn breadcrumbs(
|
||||
&self,
|
||||
theme: &theme::Theme,
|
||||
cx: &AppContext,
|
||||
) -> Option<Vec<item::BreadcrumbText>> {
|
||||
let editor = self.editor.read(cx);
|
||||
let cursor = editor.selections.newest_anchor().head();
|
||||
let multibuffer = &editor.buffer().read(cx);
|
||||
let (_, symbols) = multibuffer.symbols_containing(cursor, Some(&theme.syntax()), cx)?;
|
||||
|
||||
let settings = ThemeSettings::get_global(cx);
|
||||
|
||||
let mut breadcrumbs = Vec::new();
|
||||
|
||||
let title = self.title(cx);
|
||||
if title.chars().count() > Self::MAX_TAB_TITLE_LEN {
|
||||
breadcrumbs.push(BreadcrumbText {
|
||||
text: title,
|
||||
highlights: None,
|
||||
font: Some(settings.buffer_font.clone()),
|
||||
});
|
||||
}
|
||||
|
||||
breadcrumbs.extend(symbols.into_iter().map(|symbol| BreadcrumbText {
|
||||
text: symbol.text,
|
||||
highlights: Some(symbol.highlight_ranges),
|
||||
font: Some(settings.buffer_font.clone()),
|
||||
}));
|
||||
Some(breadcrumbs)
|
||||
}
|
||||
|
||||
fn breadcrumb_location(&self) -> ToolbarItemLocation {
|
||||
ToolbarItemLocation::PrimaryLeft
|
||||
}
|
||||
|
||||
fn set_nav_history(&mut self, nav_history: pane::ItemNavHistory, cx: &mut ViewContext<Self>) {
|
||||
self.editor.update(cx, |editor, cx| {
|
||||
Item::set_nav_history(editor, nav_history, cx)
|
||||
|
@ -2405,18 +2460,21 @@ pub struct ContextEditorToolbarItem {
|
|||
workspace: WeakView<Workspace>,
|
||||
active_context_editor: Option<WeakView<ContextEditor>>,
|
||||
model_selector_menu_handle: PopoverMenuHandle<ContextMenu>,
|
||||
model_summary_editor: View<Editor>,
|
||||
}
|
||||
|
||||
impl ContextEditorToolbarItem {
|
||||
pub fn new(
|
||||
workspace: &Workspace,
|
||||
model_selector_menu_handle: PopoverMenuHandle<ContextMenu>,
|
||||
model_summary_editor: View<Editor>,
|
||||
) -> Self {
|
||||
Self {
|
||||
fs: workspace.app_state().fs.clone(),
|
||||
workspace: workspace.weak_handle(),
|
||||
active_context_editor: None,
|
||||
model_selector_menu_handle,
|
||||
model_summary_editor,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -2524,14 +2582,35 @@ impl ContextEditorToolbarItem {
|
|||
|
||||
impl Render for ContextEditorToolbarItem {
|
||||
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
|
||||
h_flex()
|
||||
let left_side = h_flex()
|
||||
.gap_2()
|
||||
.flex_1()
|
||||
.min_w(rems(DEFAULT_TAB_TITLE.len() as f32))
|
||||
.when(self.active_context_editor.is_some(), |left_side| {
|
||||
left_side
|
||||
.child(
|
||||
IconButton::new("regenerate-context", IconName::ArrowCircle)
|
||||
.tooltip(|cx| Tooltip::text("Regenerate Summary", cx))
|
||||
.on_click(cx.listener(move |_, _, cx| {
|
||||
cx.emit(ContextEditorToolbarItemEvent::RegenerateSummary)
|
||||
})),
|
||||
)
|
||||
.child(self.model_summary_editor.clone())
|
||||
});
|
||||
let right_side = h_flex()
|
||||
.gap_2()
|
||||
.child(ModelSelector::new(
|
||||
self.model_selector_menu_handle.clone(),
|
||||
self.fs.clone(),
|
||||
))
|
||||
.children(self.render_remaining_tokens(cx))
|
||||
.child(self.render_inject_context_menu(cx))
|
||||
.child(self.render_inject_context_menu(cx));
|
||||
|
||||
h_flex()
|
||||
.size_full()
|
||||
.justify_between()
|
||||
.child(left_side)
|
||||
.child(right_side)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -2559,6 +2638,11 @@ impl ToolbarItemView for ContextEditorToolbarItem {
|
|||
|
||||
impl EventEmitter<ToolbarItemEvent> for ContextEditorToolbarItem {}
|
||||
|
||||
enum ContextEditorToolbarItemEvent {
|
||||
RegenerateSummary,
|
||||
}
|
||||
impl EventEmitter<ContextEditorToolbarItemEvent> for ContextEditorToolbarItem {}
|
||||
|
||||
pub struct ContextHistory {
|
||||
picker: View<Picker<SavedContextPickerDelegate>>,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
|
|
|
@ -9,7 +9,7 @@ use assistant_slash_command::{
|
|||
use client::{self, proto, telemetry::Telemetry};
|
||||
use clock::ReplicaId;
|
||||
use collections::{HashMap, HashSet};
|
||||
use fs::Fs;
|
||||
use fs::{Fs, RemoveOptions};
|
||||
use futures::{
|
||||
future::{self, Shared},
|
||||
FutureExt, StreamExt,
|
||||
|
@ -1675,7 +1675,7 @@ impl Context {
|
|||
this.update(&mut cx, |this, cx| {
|
||||
this.pending_completions
|
||||
.retain(|completion| completion.id != this.completion_count);
|
||||
this.summarize(cx);
|
||||
this.summarize(false, cx);
|
||||
})?;
|
||||
|
||||
anyhow::Ok(())
|
||||
|
@ -1968,8 +1968,8 @@ impl Context {
|
|||
self.message_anchors.insert(insertion_ix, new_anchor);
|
||||
}
|
||||
|
||||
fn summarize(&mut self, cx: &mut ModelContext<Self>) {
|
||||
if self.message_anchors.len() >= 2 && self.summary.is_none() {
|
||||
pub(super) fn summarize(&mut self, replace_old: bool, cx: &mut ModelContext<Self>) {
|
||||
if replace_old || (self.message_anchors.len() >= 2 && self.summary.is_none()) {
|
||||
if !CompletionProvider::global(cx).is_authenticated() {
|
||||
return;
|
||||
}
|
||||
|
@ -1993,13 +1993,18 @@ impl Context {
|
|||
async move {
|
||||
let mut messages = stream.await?;
|
||||
|
||||
let mut replaced = !replace_old;
|
||||
while let Some(message) = messages.next().await {
|
||||
let text = message?;
|
||||
let mut lines = text.lines();
|
||||
this.update(&mut cx, |this, cx| {
|
||||
let version = this.version.clone();
|
||||
let timestamp = this.next_timestamp();
|
||||
let summary = this.summary.get_or_insert(Default::default());
|
||||
let summary = this.summary.get_or_insert(ContextSummary::default());
|
||||
if !replaced && replace_old {
|
||||
summary.text.clear();
|
||||
replaced = true;
|
||||
}
|
||||
summary.text.extend(lines.next());
|
||||
summary.timestamp = timestamp;
|
||||
let operation = ContextOperation::UpdateSummary {
|
||||
|
@ -2142,9 +2147,6 @@ impl Context {
|
|||
|
||||
if let Some(summary) = summary {
|
||||
let context = this.read_with(&cx, |this, cx| this.serialize(cx))?;
|
||||
let path = if let Some(old_path) = old_path {
|
||||
old_path
|
||||
} else {
|
||||
let mut discriminant = 1;
|
||||
let mut new_path;
|
||||
loop {
|
||||
|
@ -2159,18 +2161,38 @@ impl Context {
|
|||
break;
|
||||
}
|
||||
}
|
||||
new_path
|
||||
};
|
||||
|
||||
fs.create_dir(contexts_dir().as_ref()).await?;
|
||||
fs.atomic_write(path.clone(), serde_json::to_string(&context).unwrap())
|
||||
fs.atomic_write(new_path.clone(), serde_json::to_string(&context).unwrap())
|
||||
.await?;
|
||||
this.update(&mut cx, |this, _| this.path = Some(path))?;
|
||||
if let Some(old_path) = old_path {
|
||||
if new_path != old_path {
|
||||
fs.remove_file(
|
||||
&old_path,
|
||||
RemoveOptions {
|
||||
recursive: false,
|
||||
ignore_if_not_exists: true,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
this.update(&mut cx, |this, _| this.path = Some(new_path))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn custom_summary(&mut self, custom_summary: String, cx: &mut ModelContext<Self>) {
|
||||
let timestamp = self.next_timestamp();
|
||||
let summary = self.summary.get_or_insert(ContextSummary::default());
|
||||
summary.timestamp = timestamp;
|
||||
summary.done = true;
|
||||
summary.text = custom_summary;
|
||||
cx.emit(ContextEvent::SummaryChanged);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue