mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-20 09:24:03 +00:00
feat(webui): enhance ChatViewer and components
This commit is contained in:
parent
5900162229
commit
2ea44966ec
11 changed files with 157 additions and 52 deletions
|
|
@ -82,15 +82,16 @@
|
|||
gap: 0;
|
||||
align-items: flex-start;
|
||||
text-align: left;
|
||||
padding: 8px 0;
|
||||
padding-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
animation: chatViewerFadeIn 0.2s ease-in;
|
||||
}
|
||||
|
||||
.chat-viewer-messages > *:not(:last-child) {
|
||||
padding-bottom: 0;
|
||||
padding-top: 0;
|
||||
.chat-viewer-messages > .chat-viewer-scroll-anchor {
|
||||
padding: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Disable overflow anchoring on individual items for manual scroll control */
|
||||
|
|
@ -103,15 +104,6 @@
|
|||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* ===========================
|
||||
Timeline Wrapper Styles
|
||||
=========================== */
|
||||
|
||||
/* Tool call wrapper - transparent container for timeline continuity */
|
||||
.chat-viewer-toolcall-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* ===========================
|
||||
Animations
|
||||
=========================== */
|
||||
|
|
|
|||
|
|
@ -303,21 +303,17 @@ export const ChatViewer = forwardRef<ChatViewerHandle, ChatViewerProps>(
|
|||
if (msg.type === 'tool_call' && msg.toolCall) {
|
||||
const ToolCallComponent = getToolCallComponent(msg.toolCall.kind);
|
||||
|
||||
if (!ToolCallComponent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
<ToolCallComponent
|
||||
key={key}
|
||||
className="chat-viewer-toolcall-wrapper"
|
||||
data-first={isFirst}
|
||||
data-last={isLast}
|
||||
>
|
||||
{ToolCallComponent && (
|
||||
<ToolCallComponent
|
||||
toolCall={msg.toolCall}
|
||||
isFirst={isFirst}
|
||||
isLast={isLast}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
toolCall={msg.toolCall}
|
||||
isFirst={isFirst}
|
||||
isLast={isLast}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -380,7 +376,10 @@ export const ChatViewer = forwardRef<ChatViewerHandle, ChatViewerProps>(
|
|||
|
||||
return (
|
||||
<div className={containerClasses}>
|
||||
<div ref={scrollContainerRef} className="chat-viewer-messages">
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="chat-viewer-messages chat-messages"
|
||||
>
|
||||
{sortedMessages.length === 0 ? (
|
||||
<div className="chat-viewer-empty">
|
||||
{showEmptyIcon && (
|
||||
|
|
|
|||
|
|
@ -27,9 +27,13 @@ const EditToolCallContainer: React.FC<ToolCallContainerProps> = ({
|
|||
toolCallId: _toolCallId,
|
||||
labelSuffix,
|
||||
className: _className,
|
||||
isFirst = false,
|
||||
isLast = false,
|
||||
}) => (
|
||||
<div
|
||||
className={`qwen-message message-item ${_className || ''} relative pl-[30px] py-2 select-text toolcall-container toolcall-status-${status}`}
|
||||
data-first={isFirst}
|
||||
data-last={isLast}
|
||||
>
|
||||
<div className="EditToolCall toolcall-content-wrapper flex flex-col gap-1 min-w-0 max-w-full">
|
||||
<div className="flex items-baseline gap-1.5 relative min-w-0">
|
||||
|
|
@ -71,7 +75,11 @@ const getDiffSummary = (
|
|||
* Specialized component for Edit tool calls
|
||||
* Optimized for displaying file editing operations with diffs
|
||||
*/
|
||||
export const EditToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
|
||||
export const EditToolCall: React.FC<BaseToolCallProps> = ({
|
||||
toolCall,
|
||||
isFirst,
|
||||
isLast,
|
||||
}) => {
|
||||
const { content, locations, toolCallId } = toolCall;
|
||||
|
||||
// Group content by type; memoize to avoid new array identities on every render
|
||||
|
|
@ -85,6 +93,8 @@ export const EditToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
|
|||
return (
|
||||
<div
|
||||
className={`qwen-message message-item relative py-2 select-text toolcall-container toolcall-status-${containerStatus}`}
|
||||
data-first={isFirst}
|
||||
data-last={isLast}
|
||||
>
|
||||
<div className="toolcall-edit-content flex flex-col gap-1 min-w-0 max-w-full">
|
||||
<div className="flex items-center justify-between min-w-0">
|
||||
|
|
@ -118,6 +128,8 @@ export const EditToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
|
|||
label={'Edit'}
|
||||
status="error"
|
||||
toolCallId={toolCallId}
|
||||
isFirst={isFirst}
|
||||
isLast={isLast}
|
||||
labelSuffix={
|
||||
path ? (
|
||||
<FileLink
|
||||
|
|
@ -142,6 +154,8 @@ export const EditToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
|
|||
return (
|
||||
<div
|
||||
className={`qwen-message message-item relative py-2 select-text toolcall-container toolcall-status-${containerStatus}`}
|
||||
data-first={isFirst}
|
||||
data-last={isLast}
|
||||
>
|
||||
<div className="toolcall-edit-content flex flex-col gap-1 min-w-0 max-w-full">
|
||||
<div className="flex items-center justify-between min-w-0">
|
||||
|
|
@ -175,6 +189,8 @@ export const EditToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
|
|||
label={`Edit`}
|
||||
status={containerStatus}
|
||||
toolCallId={toolCallId}
|
||||
isFirst={isFirst}
|
||||
isLast={isLast}
|
||||
labelSuffix={
|
||||
<FileLink
|
||||
path={locations[0].path}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,11 @@ import type { BaseToolCallProps } from './shared/index.js';
|
|||
* Used as fallback for unknown tool call kinds
|
||||
* Minimal display: show description and outcome
|
||||
*/
|
||||
export const GenericToolCall: FC<BaseToolCallProps> = ({ toolCall }) => {
|
||||
export const GenericToolCall: FC<BaseToolCallProps> = ({
|
||||
toolCall,
|
||||
isFirst,
|
||||
isLast,
|
||||
}) => {
|
||||
const { kind, title, content, locations, toolCallId } = toolCall;
|
||||
const operationText = safeTitle(title);
|
||||
|
||||
|
|
@ -94,6 +98,8 @@ export const GenericToolCall: FC<BaseToolCallProps> = ({ toolCall }) => {
|
|||
label={getDisplayLabel()}
|
||||
status={statusFlag}
|
||||
toolCallId={toolCallId}
|
||||
isFirst={isFirst}
|
||||
isLast={isLast}
|
||||
>
|
||||
{operationText || output}
|
||||
</ToolCallContainer>
|
||||
|
|
@ -111,6 +117,8 @@ export const GenericToolCall: FC<BaseToolCallProps> = ({ toolCall }) => {
|
|||
label={getDisplayLabel()}
|
||||
status={statusFlag}
|
||||
toolCallId={toolCallId}
|
||||
isFirst={isFirst}
|
||||
isLast={isLast}
|
||||
>
|
||||
<LocationsList locations={locations} />
|
||||
</ToolCallContainer>
|
||||
|
|
@ -128,6 +136,8 @@ export const GenericToolCall: FC<BaseToolCallProps> = ({ toolCall }) => {
|
|||
label={getDisplayLabel()}
|
||||
status={statusFlag}
|
||||
toolCallId={toolCallId}
|
||||
isFirst={isFirst}
|
||||
isLast={isLast}
|
||||
>
|
||||
{operationText}
|
||||
</ToolCallContainer>
|
||||
|
|
|
|||
|
|
@ -30,9 +30,13 @@ const ReadToolCallContainer: FC<ToolCallContainerProps> = ({
|
|||
toolCallId: _toolCallId,
|
||||
labelSuffix,
|
||||
className: _className,
|
||||
isFirst = false,
|
||||
isLast = false,
|
||||
}) => (
|
||||
<div
|
||||
className={`ReadToolCall qwen-message message-item ${_className || ''} relative pl-[30px] py-2 select-text toolcall-container toolcall-status-${status}`}
|
||||
data-first={isFirst}
|
||||
data-last={isLast}
|
||||
>
|
||||
<div className="toolcall-content-wrapper flex flex-col gap-1 min-w-0 max-w-full">
|
||||
<div className="flex items-baseline gap-1.5 relative min-w-0">
|
||||
|
|
@ -56,7 +60,11 @@ const ReadToolCallContainer: FC<ToolCallContainerProps> = ({
|
|||
* ReadToolCall - displays file reading operations
|
||||
* Shows: Read filename (no content preview)
|
||||
*/
|
||||
export const ReadToolCall: FC<BaseToolCallProps> = ({ toolCall }) => {
|
||||
export const ReadToolCall: FC<BaseToolCallProps> = ({
|
||||
toolCall,
|
||||
isFirst,
|
||||
isLast,
|
||||
}) => {
|
||||
const { content, locations, toolCallId } = toolCall;
|
||||
const platform = usePlatform();
|
||||
const openedDiffsRef = useRef<Map<string, string>>(new Map());
|
||||
|
|
@ -137,6 +145,8 @@ export const ReadToolCall: FC<BaseToolCallProps> = ({ toolCall }) => {
|
|||
className="read-tool-call-error"
|
||||
status="error"
|
||||
toolCallId={toolCallId}
|
||||
isFirst={isFirst}
|
||||
isLast={isLast}
|
||||
labelSuffix={
|
||||
path ? (
|
||||
<FileLink
|
||||
|
|
@ -163,6 +173,8 @@ export const ReadToolCall: FC<BaseToolCallProps> = ({ toolCall }) => {
|
|||
className="read-tool-call-error"
|
||||
status="error"
|
||||
toolCallId={toolCallId}
|
||||
isFirst={isFirst}
|
||||
isLast={isLast}
|
||||
labelSuffix={
|
||||
path ? (
|
||||
<FileLink
|
||||
|
|
@ -187,6 +199,8 @@ export const ReadToolCall: FC<BaseToolCallProps> = ({ toolCall }) => {
|
|||
className="read-tool-call-success"
|
||||
status={containerStatus}
|
||||
toolCallId={toolCallId}
|
||||
isFirst={isFirst}
|
||||
isLast={isLast}
|
||||
labelSuffix={
|
||||
path ? (
|
||||
<FileLink
|
||||
|
|
@ -211,6 +225,8 @@ export const ReadToolCall: FC<BaseToolCallProps> = ({ toolCall }) => {
|
|||
className="read-tool-call-success"
|
||||
status={containerStatus}
|
||||
toolCallId={toolCallId}
|
||||
isFirst={isFirst}
|
||||
isLast={isLast}
|
||||
labelSuffix={
|
||||
path ? (
|
||||
<FileLink
|
||||
|
|
|
|||
|
|
@ -34,11 +34,15 @@ const ExecuteToolCallContainer: FC<ToolCallContainerProps> = ({
|
|||
toolCallId: _toolCallId,
|
||||
labelSuffix,
|
||||
className: _className,
|
||||
isFirst = false,
|
||||
isLast = false,
|
||||
}) => (
|
||||
<div
|
||||
className={`ExecuteToolCall qwen-message message-item ${_className || ''} relative pl-[30px] py-2 select-text toolcall-container toolcall-status-${status}`}
|
||||
data-first={isFirst}
|
||||
data-last={isLast}
|
||||
>
|
||||
<div className="toolcall-content-wrapper flex flex-col gap-0 min-w-0 max-w-full">
|
||||
<div className="toolcall-content-wrapper flex flex-col min-w-0 max-w-full">
|
||||
<div className="flex items-baseline gap-1.5 relative min-w-0">
|
||||
<span className="text-[14px] leading-none font-bold text-[var(--app-primary-foreground)]">
|
||||
{label}
|
||||
|
|
@ -95,6 +99,8 @@ const getInputCommand = (
|
|||
const ShellToolCallImpl: FC<BaseToolCallProps & { variant: ShellVariant }> = ({
|
||||
toolCall,
|
||||
variant,
|
||||
isFirst,
|
||||
isLast,
|
||||
}) => {
|
||||
const { title, content, rawInput, toolCallId } = toolCall;
|
||||
const classPrefix = variant;
|
||||
|
|
@ -157,7 +163,13 @@ const ShellToolCallImpl: FC<BaseToolCallProps & { variant: ShellVariant }> = ({
|
|||
// Error case
|
||||
if (errors.length > 0) {
|
||||
return (
|
||||
<Container label={label} status={containerStatus} toolCallId={toolCallId}>
|
||||
<Container
|
||||
label={label}
|
||||
status={containerStatus}
|
||||
toolCallId={toolCallId}
|
||||
isFirst={isFirst}
|
||||
isLast={isLast}
|
||||
>
|
||||
{/* Branch connector summary */}
|
||||
<div className="inline-flex text-[var(--app-secondary-foreground)] text-[0.85em] opacity-70 mt-[2px] mb-[2px] flex-row items-start w-full gap-1">
|
||||
<span className="flex-shrink-0 relative top-[-0.1em]">⎿</span>
|
||||
|
|
@ -203,7 +215,13 @@ const ShellToolCallImpl: FC<BaseToolCallProps & { variant: ShellVariant }> = ({
|
|||
output.length > 500 ? output.substring(0, 500) + '...' : output;
|
||||
|
||||
return (
|
||||
<Container label={label} status={containerStatus} toolCallId={toolCallId}>
|
||||
<Container
|
||||
label={label}
|
||||
status={containerStatus}
|
||||
toolCallId={toolCallId}
|
||||
isFirst={isFirst}
|
||||
isLast={isLast}
|
||||
>
|
||||
{/* Branch connector summary */}
|
||||
<div className="inline-flex text-[var(--app-secondary-foreground)] text-[0.85em] opacity-70 mt-[2px] mb-[2px] flex-row items-start w-full gap-1">
|
||||
<span className="flex-shrink-0 relative top-[-0.1em]">⎿</span>
|
||||
|
|
@ -248,7 +266,13 @@ const ShellToolCallImpl: FC<BaseToolCallProps & { variant: ShellVariant }> = ({
|
|||
|
||||
// Success without output: show command with branch connector
|
||||
return (
|
||||
<Container label={label} status={containerStatus} toolCallId={toolCallId}>
|
||||
<Container
|
||||
label={label}
|
||||
status={containerStatus}
|
||||
toolCallId={toolCallId}
|
||||
isFirst={isFirst}
|
||||
isLast={isLast}
|
||||
>
|
||||
<div
|
||||
className="inline-flex text-[var(--app-secondary-foreground)] text-[0.85em] opacity-70 mt-[2px] mb-[2px] flex-row items-start w-full gap-1"
|
||||
onClick={handleInClick}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,11 @@ import type { BaseToolCallProps } from './shared/index.js';
|
|||
* Optimized for displaying AI reasoning and thought processes
|
||||
* Minimal display: just show the thoughts (no context)
|
||||
*/
|
||||
export const ThinkToolCall: FC<BaseToolCallProps> = ({ toolCall }) => {
|
||||
export const ThinkToolCall: FC<BaseToolCallProps> = ({
|
||||
toolCall,
|
||||
isFirst,
|
||||
isLast,
|
||||
}) => {
|
||||
const { content } = toolCall;
|
||||
|
||||
// Group content by type
|
||||
|
|
@ -29,7 +33,12 @@ export const ThinkToolCall: FC<BaseToolCallProps> = ({ toolCall }) => {
|
|||
// Error case (rare for thinking)
|
||||
if (errors.length > 0) {
|
||||
return (
|
||||
<ToolCallContainer label="SaveMemory" status="error">
|
||||
<ToolCallContainer
|
||||
label="SaveMemory"
|
||||
status="error"
|
||||
isFirst={isFirst}
|
||||
isLast={isLast}
|
||||
>
|
||||
{errors.join('\n')}
|
||||
</ToolCallContainer>
|
||||
);
|
||||
|
|
@ -61,7 +70,12 @@ export const ThinkToolCall: FC<BaseToolCallProps> = ({ toolCall }) => {
|
|||
? 'loading'
|
||||
: 'default';
|
||||
return (
|
||||
<ToolCallContainer label="SaveMemory" status={status}>
|
||||
<ToolCallContainer
|
||||
label="SaveMemory"
|
||||
status={status}
|
||||
isFirst={isFirst}
|
||||
isLast={isLast}
|
||||
>
|
||||
<span className="italic opacity-90">{thoughts}</span>
|
||||
</ToolCallContainer>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -27,9 +27,13 @@ const PlanToolCallContainer: FC<ToolCallContainerProps> = ({
|
|||
toolCallId: _toolCallId,
|
||||
labelSuffix,
|
||||
className: _className,
|
||||
isFirst = false,
|
||||
isLast = false,
|
||||
}) => (
|
||||
<div
|
||||
className={`qwen-message message-item ${_className || ''} relative pl-[30px] py-2 select-text toolcall-container toolcall-status-${status}`}
|
||||
data-first={isFirst}
|
||||
data-last={isLast}
|
||||
>
|
||||
<div className="UpdatedPlanToolCall toolcall-content-wrapper flex flex-col gap-2 min-w-0 max-w-full">
|
||||
<div className="flex items-baseline gap-1 relative min-w-0">
|
||||
|
|
@ -113,14 +117,23 @@ const parsePlanEntries = (textOutputs: string[]): PlanEntry[] => {
|
|||
* Specialized component for UpdatedPlan tool calls
|
||||
* Optimized for displaying plan update operations
|
||||
*/
|
||||
export const UpdatedPlanToolCall: FC<BaseToolCallProps> = ({ toolCall }) => {
|
||||
export const UpdatedPlanToolCall: FC<BaseToolCallProps> = ({
|
||||
toolCall,
|
||||
isFirst,
|
||||
isLast,
|
||||
}) => {
|
||||
const { content, status } = toolCall;
|
||||
const { errors, textOutputs } = groupContent(content);
|
||||
|
||||
// Error-first display
|
||||
if (errors.length > 0) {
|
||||
return (
|
||||
<PlanToolCallContainer label="TodoWrite" status="error">
|
||||
<PlanToolCallContainer
|
||||
label="TodoWrite"
|
||||
status="error"
|
||||
isFirst={isFirst}
|
||||
isLast={isLast}
|
||||
>
|
||||
{errors.join('\n')}
|
||||
</PlanToolCallContainer>
|
||||
);
|
||||
|
|
@ -134,6 +147,8 @@ export const UpdatedPlanToolCall: FC<BaseToolCallProps> = ({ toolCall }) => {
|
|||
label={label}
|
||||
status={mapToolStatusToBullet(status)}
|
||||
className="update-plan-toolcall"
|
||||
isFirst={isFirst}
|
||||
isLast={isLast}
|
||||
>
|
||||
<ul className="Fr list-none p-0 m-0 flex flex-col gap-1">
|
||||
{entries.map((entry, idx) => {
|
||||
|
|
|
|||
|
|
@ -19,7 +19,11 @@ import { FileLink } from '../layout/FileLink.js';
|
|||
* Specialized component for Write tool calls
|
||||
* Shows: Write filename + error message + content preview
|
||||
*/
|
||||
export const WriteToolCall: FC<BaseToolCallProps> = ({ toolCall }) => {
|
||||
export const WriteToolCall: FC<BaseToolCallProps> = ({
|
||||
toolCall,
|
||||
isFirst,
|
||||
isLast,
|
||||
}) => {
|
||||
const { content, locations, rawInput, toolCallId } = toolCall;
|
||||
|
||||
// Group content by type
|
||||
|
|
@ -50,6 +54,8 @@ export const WriteToolCall: FC<BaseToolCallProps> = ({ toolCall }) => {
|
|||
label={'WriteFile'}
|
||||
status="error"
|
||||
toolCallId={toolCallId}
|
||||
isFirst={isFirst}
|
||||
isLast={isLast}
|
||||
labelSuffix={
|
||||
path ? (
|
||||
<FileLink
|
||||
|
|
@ -85,6 +91,8 @@ export const WriteToolCall: FC<BaseToolCallProps> = ({ toolCall }) => {
|
|||
label={'WriteFile'}
|
||||
status={containerStatus}
|
||||
toolCallId={toolCallId}
|
||||
isFirst={isFirst}
|
||||
isLast={isLast}
|
||||
labelSuffix={
|
||||
path ? (
|
||||
<FileLink
|
||||
|
|
@ -111,6 +119,8 @@ export const WriteToolCall: FC<BaseToolCallProps> = ({ toolCall }) => {
|
|||
label="WriteFile"
|
||||
status={containerStatus}
|
||||
toolCallId={toolCallId}
|
||||
isFirst={isFirst}
|
||||
isLast={isLast}
|
||||
>
|
||||
{textOutputs.join('\n')}
|
||||
</ToolCallContainer>
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ export const ToolCallContainer: FC<ToolCallContainerProps> = ({
|
|||
data-last={isLast}
|
||||
>
|
||||
<div className="toolcall-content-wrapper flex flex-col min-w-0 max-w-full">
|
||||
<div className="flex items-baseline relative min-w-0">
|
||||
<div className="flex items-baseline gap-1.5 relative min-w-0">
|
||||
<span className="text-[14px] leading-none font-bold text-[var(--app-primary-foreground)]">
|
||||
{label}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -37,22 +37,31 @@
|
|||
.qwen-message.message-item:not(.user-message-container):first-child::after,
|
||||
.user-message-container
|
||||
+ .qwen-message.message-item:not(.user-message-container)::after,
|
||||
.chat-viewer-toolcall-wrapper:first-child .toolcall-container::after,
|
||||
.user-message-container + .chat-viewer-toolcall-wrapper .toolcall-container::after {
|
||||
.chat-messages
|
||||
> :not(.qwen-message.message-item)
|
||||
+ .qwen-message.message-item:not(.user-message-container)::after,
|
||||
.chat-viewer-messages
|
||||
> :not(.qwen-message.message-item)
|
||||
+ .qwen-message.message-item:not(.user-message-container)::after {
|
||||
top: var(--timeline-center-offset, 13px);
|
||||
}
|
||||
|
||||
.qwen-message.message-item:not(.user-message-container):has(+ .user-message-container)::after,
|
||||
.qwen-message.message-item:not(.user-message-container):has(
|
||||
+ :not(.qwen-message.message-item):not(.chat-viewer-toolcall-wrapper)
|
||||
+ :not(.qwen-message.message-item)
|
||||
)::after,
|
||||
.qwen-message.message-item:not(.user-message-container):last-child::after,
|
||||
.chat-viewer-toolcall-wrapper:has(+ .user-message-container)
|
||||
.toolcall-container::after,
|
||||
.chat-viewer-toolcall-wrapper:has(
|
||||
+ :not(.chat-viewer-toolcall-wrapper):not(.qwen-message.message-item)
|
||||
)
|
||||
.toolcall-container::after,
|
||||
.chat-viewer-toolcall-wrapper:last-child .toolcall-container::after {
|
||||
.qwen-message.message-item:not(.user-message-container):last-child::after {
|
||||
bottom: calc(100% - var(--timeline-center-offset, 13px));
|
||||
}
|
||||
|
||||
.qwen-message.message-item[data-first='true']::after {
|
||||
top: var(--timeline-center-offset, 13px);
|
||||
}
|
||||
|
||||
.qwen-message.message-item[data-last='true']::after {
|
||||
bottom: calc(100% - var(--timeline-center-offset, 13px));
|
||||
}
|
||||
|
||||
.qwen-message.message-item[data-first='true'][data-last='true']::after {
|
||||
display: none;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue