feat(webui): publish webui

0.1.0-beta.3 add collapsibaleFileContext compent and opt SearchToolCall
This commit is contained in:
yiliang114 2026-01-28 17:24:31 +08:00
parent cf32299b5f
commit 76505c635e
6 changed files with 326 additions and 40 deletions

View file

@ -1,6 +1,6 @@
{
"name": "@qwen-code/webui",
"version": "0.1.0-beta.2",
"version": "0.1.0-beta.3",
"description": "Shared UI components for Qwen Code packages",
"type": "module",
"main": "./dist/index.cjs",

View file

@ -0,0 +1,261 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type { FC } from 'react';
import { useState, useMemo } from 'react';
import { MessageContent } from './MessageContent.js';
/**
* Parsed segment of user message content
*/
export interface ContentSegment {
type: 'text' | 'file_reference';
content: string;
/** File path for file_reference type */
filePath?: string;
/** File name extracted from path */
fileName?: string;
}
/**
* Pattern markers for file reference content
*/
const FILE_REFERENCE_START = '--- Content from referenced files ---';
const FILE_REFERENCE_END = '--- End of content ---';
const FILE_CONTENT_PREFIX = /^Content from @([^\n:]+):\n?/m;
/**
* Parse content to identify file references and regular text
* @param content - The raw content string
* @returns Array of content segments
*/
export function parseContentWithFileReferences(
content: string,
): ContentSegment[] {
const segments: ContentSegment[] = [];
// Find the file reference section
const startIndex = content.indexOf(FILE_REFERENCE_START);
const endIndex = content.indexOf(FILE_REFERENCE_END);
// No file reference section found
if (startIndex === -1) {
return [{ type: 'text', content }];
}
// Add text before file references
const textBefore = content.substring(0, startIndex).trim();
if (textBefore) {
segments.push({ type: 'text', content: textBefore });
}
// Extract file reference section
const fileRefSection =
endIndex !== -1
? content.substring(startIndex + FILE_REFERENCE_START.length, endIndex)
: content.substring(startIndex + FILE_REFERENCE_START.length);
// Parse individual file references
// Split by "Content from @" pattern
const fileRefParts = fileRefSection.split(/(?=\nContent from @)/);
for (const part of fileRefParts) {
const trimmedPart = part.trim();
if (!trimmedPart) continue;
// Try to extract file path
const match = trimmedPart.match(FILE_CONTENT_PREFIX);
if (match) {
const filePath = match[1].trim();
const fileName = filePath.split('/').pop() || filePath;
const fileContent = trimmedPart.substring(match[0].length);
segments.push({
type: 'file_reference',
content: fileContent.trim(),
filePath,
fileName,
});
} else if (trimmedPart && !trimmedPart.startsWith('Content from @')) {
// This might be content without proper prefix, still treat as file reference
segments.push({
type: 'file_reference',
content: trimmedPart,
});
}
}
// Add text after file references
if (endIndex !== -1) {
const textAfter = content
.substring(endIndex + FILE_REFERENCE_END.length)
.trim();
if (textAfter) {
segments.push({ type: 'text', content: textAfter });
}
}
return segments;
}
/**
* Props for CollapsibleFileReference
*/
interface CollapsibleFileReferenceProps {
segment: ContentSegment;
onFileClick?: (path: string) => void;
defaultExpanded?: boolean;
}
/**
* CollapsibleFileReference - A single collapsible file reference block
*/
const CollapsibleFileReference: FC<CollapsibleFileReferenceProps> = ({
segment,
onFileClick,
defaultExpanded = false,
}) => {
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
const lineCount = useMemo(() => segment.content.split('\n').length, [segment.content]);
const handleToggle = () => {
setIsExpanded(!isExpanded);
};
const handleFileClick = () => {
if (segment.filePath && onFileClick) {
onFileClick(segment.filePath);
}
};
return (
<div
className="rounded-md overflow-hidden"
style={{
border: '1px solid var(--app-input-border)',
backgroundColor: 'var(--app-secondary-background)',
}}
>
<button
type="button"
className="flex items-center gap-1.5 w-full py-1.5 px-2.5 bg-transparent border-none cursor-pointer text-left text-xs transition-colors duration-150 hover:bg-black/5"
style={{ color: 'var(--app-secondary-foreground)' }}
onClick={handleToggle}
aria-expanded={isExpanded}
>
<span
className="text-[8px] flex-shrink-0 transition-transform duration-200"
style={{
color: 'var(--app-secondary-foreground)',
transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)',
}}
>
</span>
<span className="text-sm flex-shrink-0">📄</span>
<span
className="font-medium cursor-pointer overflow-hidden text-ellipsis whitespace-nowrap flex-1 min-w-0 hover:underline"
style={{ color: 'var(--app-link-color, #0066cc)' }}
onClick={(e) => {
e.stopPropagation();
handleFileClick();
}}
title={segment.filePath}
>
{segment.fileName || 'Referenced file'}
</span>
<span
className="text-[11px] flex-shrink-0 ml-auto"
style={{ color: 'var(--app-tertiary-foreground, #999)' }}
>
{lineCount} {lineCount === 1 ? 'line' : 'lines'}
</span>
</button>
{isExpanded && (
<div
className="py-2 px-2.5 max-h-[300px] overflow-y-auto text-xs leading-normal"
style={{
borderTop: '1px solid var(--app-input-border)',
backgroundColor: 'var(--app-primary-background)',
}}
>
<MessageContent
content={segment.content}
onFileClick={onFileClick}
enableFileLinks={true}
/>
</div>
)}
</div>
);
};
/**
* Props for CollapsibleFileContent
*/
export interface CollapsibleFileContentProps {
content: string;
onFileClick?: (path: string) => void;
enableFileLinks?: boolean;
}
/**
* CollapsibleFileContent - Renders content with collapsible file references
*
* Detects file reference patterns in user messages and renders them as
* collapsible blocks to improve readability.
*/
export const CollapsibleFileContent: FC<CollapsibleFileContentProps> = ({
content,
onFileClick,
enableFileLinks = false,
}) => {
const segments = useMemo(
() => parseContentWithFileReferences(content),
[content],
);
// If no file references found, render as normal content
if (segments.length === 1 && segments[0].type === 'text') {
return (
<MessageContent
content={content}
onFileClick={onFileClick}
enableFileLinks={enableFileLinks}
/>
);
}
return (
<div className="flex flex-col gap-2">
{segments.map((segment, index) => {
if (segment.type === 'text') {
return (
<div key={index}>
<MessageContent
content={segment.content}
onFileClick={onFileClick}
enableFileLinks={enableFileLinks}
/>
</div>
);
}
return (
<CollapsibleFileReference
key={index}
segment={segment}
onFileClick={onFileClick}
defaultExpanded={false}
/>
);
})}
</div>
);
};
export default CollapsibleFileContent;

View file

@ -5,7 +5,7 @@
*/
import type { FC } from 'react';
import { MessageContent } from './MessageContent.js';
import { CollapsibleFileContent } from './CollapsibleFileContent.js';
export interface FileContext {
fileName: string;
@ -59,7 +59,7 @@ export const UserMessage: FC<UserMessageProps> = ({
color: 'var(--app-primary-foreground)',
}}
>
<MessageContent
<CollapsibleFileContent
content={content}
onFileClick={onFileClick}
enableFileLinks={false}

View file

@ -6,7 +6,7 @@
* Search tool call component - specialized for search operations
*/
import type { FC } from 'react';
import { useState, type FC } from 'react';
import {
safeTitle,
groupContent,
@ -16,6 +16,38 @@ import {
import type { BaseToolCallProps, ContainerStatus } from './shared/index.js';
import { FileLink } from '../layout/FileLink.js';
/**
* Collapsible output component for search results
* Shows a summary line that can be expanded to show full content
*/
const CollapsibleOutput: FC<{
/** Summary text to show when collapsed (e.g., "21 lines of output") */
summary: string;
/** Content to show when expanded */
children: React.ReactNode;
/** Whether to start expanded (default: false) */
defaultExpanded?: boolean;
}> = ({ summary, children, defaultExpanded = false }) => {
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
return (
<div className="flex flex-col">
<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 cursor-pointer hover:opacity-100 transition-opacity"
onClick={() => setIsExpanded(!isExpanded)}
>
<span className="flex-shrink-0 relative top-[-0.1em]"></span>
<span className="flex-shrink-0">{summary}</span>
</div>
{isExpanded && (
<div className="ml-4 mt-1 text-[var(--app-secondary-foreground)] text-[0.85em]">
{children}
</div>
)}
</div>
);
};
/**
* Row component for search card layout
*/
@ -116,28 +148,8 @@ export const SearchToolCall: FC<BaseToolCallProps> = ({
// Success case with results: show search query + file list
if (locations && locations.length > 0) {
// Multiple results use card layout
if (locations.length > 1) {
return (
<ToolCallContainer
label={displayLabel}
labelSuffix={queryText}
status={containerStatus}
isFirst={isFirst}
isLast={isLast}
>
<SearchCardContent>
<SearchRow label={displayLabel}>
<div className="font-mono">{queryText}</div>
</SearchRow>
<SearchRow label={`Found (${locations.length})`}>
<LocationsListLocal locations={locations} />
</SearchRow>
</SearchCardContent>
</ToolCallContainer>
);
}
// Single result - compact format
// Use collapsible output for multiple results
const summaryText = `${locations.length} ${locations.length === 1 ? 'file' : 'files'} found`;
return (
<ToolCallContainer
label={displayLabel}
@ -146,13 +158,22 @@ export const SearchToolCall: FC<BaseToolCallProps> = ({
isFirst={isFirst}
isLast={isLast}
>
<LocationsListLocal locations={locations} />
<CollapsibleOutput summary={summaryText}>
<LocationsListLocal locations={locations} />
</CollapsibleOutput>
</ToolCallContainer>
);
}
// Show content text if available
// Show content text if available (e.g., grep output with content)
if (textOutputs.length > 0) {
// Count total lines in output
const totalLines = textOutputs.reduce(
(acc, text) => acc + text.split('\n').length,
0,
);
const summaryText = `${totalLines} ${totalLines === 1 ? 'line' : 'lines'} of output`;
return (
<ToolCallContainer
label={displayLabel}
@ -161,17 +182,13 @@ export const SearchToolCall: FC<BaseToolCallProps> = ({
isFirst={isFirst}
isLast={isLast}
>
<div className="flex flex-col">
{textOutputs.map((text: string, index: number) => (
<div
key={index}
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>
<span className="flex-shrink-0 w-full">{text}</span>
</div>
))}
</div>
<CollapsibleOutput summary={summaryText}>
<div className="flex flex-col gap-1 font-mono text-[0.85em] whitespace-pre-wrap break-all">
{textOutputs.map((text: string, index: number) => (
<div key={index}>{text}</div>
))}
</div>
</CollapsibleOutput>
</ToolCallContainer>
);
}

View file

@ -78,6 +78,14 @@ export type {
AssistantMessageProps,
AssistantMessageStatus,
} from './components/messages/Assistant/AssistantMessage';
export {
CollapsibleFileContent,
parseContentWithFileReferences,
} from './components/messages/CollapsibleFileContent';
export type {
CollapsibleFileContentProps,
ContentSegment,
} from './components/messages/CollapsibleFileContent';
// ChatViewer - standalone chat display component
export {

View file

@ -47,7 +47,7 @@ export default defineConfig({
globals: {
react: 'React',
'react-dom': 'ReactDOM',
'react/jsx-runtime': 'jsxRuntime',
'react/jsx-runtime': 'ReactJSXRuntime',
},
assetFileNames: 'styles.[ext]',
},