fix: remove dead delete+recreate and pin code, add poll fallback test
Phase 1 cleanup: - Remove deletePost() method (dead code, replaced by PUT in-place updates) - Remove _postInfo Map tracking (no longer needed) - Remove pin/unpin API calls from watcher-manager.js (incompatible with PUT updates) - Add JSDoc note on (edited) label limitation in _flushUpdate() - Add integration test: test/integration/poll-fallback.test.js - Fix addSession() lastOffset===0 falsy bug (0 was treated as 'no offset') - Fix pre-existing test failures: add lastOffset:0 where tests expect backlog reads - Fix pre-existing session-monitor test: create stub transcript files - Fix pre-existing status-formatter test: update indent check for blockquote format - Format plugin/ files with Prettier (pre-existing formatting drift)
This commit is contained in:
1934
plugin/webapp/package-lock.json
generated
Normal file
1934
plugin/webapp/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
plugin/webapp/package.json
Normal file
20
plugin/webapp/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "com.openclaw.livestatus-webapp",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "webpack --mode=production",
|
||||
"dev": "webpack --mode=development --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"css-loader": "^6.11.0",
|
||||
"style-loader": "^3.3.4",
|
||||
"ts-loader": "^9.5.4",
|
||||
"typescript": "^5.9.3",
|
||||
"webpack": "^5.105.4",
|
||||
"webpack-cli": "^5.1.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/react": "^18.2.0"
|
||||
}
|
||||
}
|
||||
180
plugin/webapp/src/components/live_status_post.tsx
Normal file
180
plugin/webapp/src/components/live_status_post.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import TerminalView from './terminal_view';
|
||||
import { LiveStatusData } from '../types';
|
||||
|
||||
interface LiveStatusPostProps {
|
||||
post: {
|
||||
id: string;
|
||||
props: Record<string, any>;
|
||||
};
|
||||
theme: Record<string, string>;
|
||||
}
|
||||
|
||||
// Global store for WebSocket updates (set by the plugin index)
|
||||
declare global {
|
||||
interface Window {
|
||||
__livestatus_updates: Record<string, LiveStatusData>;
|
||||
__livestatus_listeners: Record<string, Array<(data: LiveStatusData) => void>>;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.__livestatus_updates = window.__livestatus_updates || {};
|
||||
window.__livestatus_listeners = window.__livestatus_listeners || {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to live status updates for a given post ID.
|
||||
*/
|
||||
function useStatusUpdates(
|
||||
postId: string,
|
||||
initialData: LiveStatusData | null,
|
||||
): LiveStatusData | null {
|
||||
const [data, setData] = useState<LiveStatusData | null>(
|
||||
window.__livestatus_updates[postId] || initialData,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Register listener
|
||||
if (!window.__livestatus_listeners[postId]) {
|
||||
window.__livestatus_listeners[postId] = [];
|
||||
}
|
||||
const listener = (newData: LiveStatusData) => setData(newData);
|
||||
window.__livestatus_listeners[postId].push(listener);
|
||||
|
||||
// Check if we already have data
|
||||
if (window.__livestatus_updates[postId]) {
|
||||
setData(window.__livestatus_updates[postId]);
|
||||
}
|
||||
|
||||
return () => {
|
||||
const listeners = window.__livestatus_listeners[postId];
|
||||
if (listeners) {
|
||||
const idx = listeners.indexOf(listener);
|
||||
if (idx >= 0) listeners.splice(idx, 1);
|
||||
}
|
||||
};
|
||||
}, [postId]);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format elapsed time in human-readable form.
|
||||
*/
|
||||
function formatElapsed(ms: number): string {
|
||||
if (ms < 0) ms = 0;
|
||||
const s = Math.floor(ms / 1000);
|
||||
const m = Math.floor(s / 60);
|
||||
const h = Math.floor(m / 60);
|
||||
if (h > 0) return `${h}h${m % 60}m`;
|
||||
if (m > 0) return `${m}m${s % 60}s`;
|
||||
return `${s}s`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format token count compactly.
|
||||
*/
|
||||
function formatTokens(count: number): string {
|
||||
if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M`;
|
||||
if (count >= 1000) return `${(count / 1000).toFixed(1)}k`;
|
||||
return String(count);
|
||||
}
|
||||
|
||||
/**
|
||||
* LiveStatusPost — custom post type component.
|
||||
* Renders a terminal-style live status view with auto-updating content.
|
||||
*/
|
||||
const LiveStatusPost: React.FC<LiveStatusPostProps> = ({ post, theme }) => {
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
|
||||
// Build initial data from post props
|
||||
const initialData: LiveStatusData = {
|
||||
session_key: post.props.session_key || '',
|
||||
post_id: post.id,
|
||||
agent_id: post.props.agent_id || 'unknown',
|
||||
status: post.props.status || 'active',
|
||||
lines: post.props.final_lines || [],
|
||||
elapsed_ms: post.props.elapsed_ms || 0,
|
||||
token_count: post.props.token_count || 0,
|
||||
children: [],
|
||||
start_time_ms: post.props.start_time_ms || 0,
|
||||
};
|
||||
|
||||
const data = useStatusUpdates(post.id, initialData);
|
||||
const isActive = data?.status === 'active';
|
||||
|
||||
// Client-side elapsed time counter (ticks every 1s when active)
|
||||
useEffect(() => {
|
||||
if (!isActive || !data?.start_time_ms) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setElapsed(Date.now() - data.start_time_ms);
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isActive, data?.start_time_ms]);
|
||||
|
||||
if (!data) {
|
||||
return <div className="ls-post ls-loading">Loading status...</div>;
|
||||
}
|
||||
|
||||
const displayElapsed =
|
||||
isActive && data.start_time_ms
|
||||
? formatElapsed(elapsed || Date.now() - data.start_time_ms)
|
||||
: formatElapsed(data.elapsed_ms);
|
||||
|
||||
const statusClass = `ls-status-${data.status}`;
|
||||
|
||||
return (
|
||||
<div className={`ls-post ${statusClass}`}>
|
||||
<div className="ls-header">
|
||||
<span className="ls-agent-badge">{data.agent_id}</span>
|
||||
{isActive && <span className="ls-live-dot" />}
|
||||
<span className={`ls-status-badge ${statusClass}`}>{data.status.toUpperCase()}</span>
|
||||
<span className="ls-elapsed">{displayElapsed}</span>
|
||||
</div>
|
||||
|
||||
<TerminalView lines={data.lines} maxLines={30} />
|
||||
|
||||
{data.children && data.children.length > 0 && (
|
||||
<div className="ls-children">
|
||||
{data.children.map((child, i) => (
|
||||
<ChildSession key={child.session_key || i} child={child} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isActive && data.token_count > 0 && (
|
||||
<div className="ls-footer">{formatTokens(data.token_count)} tokens</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders a child/sub-agent session (collapsed by default).
|
||||
*/
|
||||
const ChildSession: React.FC<{ child: LiveStatusData }> = ({ child }) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="ls-child">
|
||||
<div
|
||||
className="ls-child-header"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<span className="ls-expand-icon">{expanded ? '\u25BC' : '\u25B6'}</span>
|
||||
<span className="ls-agent-badge ls-child-badge">{child.agent_id}</span>
|
||||
<span className={`ls-status-badge ls-status-${child.status}`}>
|
||||
{child.status.toUpperCase()}
|
||||
</span>
|
||||
<span className="ls-elapsed">{formatElapsed(child.elapsed_ms)}</span>
|
||||
</div>
|
||||
{expanded && <TerminalView lines={child.lines} maxLines={15} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LiveStatusPost;
|
||||
41
plugin/webapp/src/components/status_line.tsx
Normal file
41
plugin/webapp/src/components/status_line.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
|
||||
interface StatusLineProps {
|
||||
line: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single status line with formatting:
|
||||
* - Tool calls: monospace tool name with colored result marker
|
||||
* - Thinking text: dimmed with box-drawing prefix
|
||||
*/
|
||||
const StatusLine: React.FC<StatusLineProps> = ({ line }) => {
|
||||
// Match tool call pattern: "toolName: arguments [OK]" or "toolName: arguments [ERR]"
|
||||
const toolMatch = line.match(/^(\S+?): (.+?)( \[OK\]| \[ERR\])?$/);
|
||||
|
||||
if (toolMatch) {
|
||||
const toolName = toolMatch[1];
|
||||
const args = toolMatch[2];
|
||||
const marker = (toolMatch[3] || '').trim();
|
||||
|
||||
return (
|
||||
<div className="ls-status-line ls-tool-call">
|
||||
<span className="ls-tool-name">{toolName}:</span>
|
||||
<span className="ls-tool-args"> {args}</span>
|
||||
{marker && (
|
||||
<span className={marker === '[OK]' ? 'ls-marker-ok' : 'ls-marker-err'}> {marker}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Thinking text
|
||||
return (
|
||||
<div className="ls-status-line ls-thinking">
|
||||
<span className="ls-thinking-prefix">{'\u2502'} </span>
|
||||
<span className="ls-thinking-text">{line}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatusLine;
|
||||
46
plugin/webapp/src/components/terminal_view.tsx
Normal file
46
plugin/webapp/src/components/terminal_view.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import React, { useRef, useEffect, useState } from 'react';
|
||||
import StatusLine from './status_line';
|
||||
|
||||
interface TerminalViewProps {
|
||||
lines: string[];
|
||||
maxLines?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminal-style scrolling container for status lines.
|
||||
* Auto-scrolls to bottom on new content unless user has scrolled up.
|
||||
*/
|
||||
const TerminalView: React.FC<TerminalViewProps> = ({ lines, maxLines = 30 }) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [userScrolled, setUserScrolled] = useState(false);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Auto-scroll to bottom when new lines arrive (unless user scrolled up)
|
||||
useEffect(() => {
|
||||
if (!userScrolled && bottomRef.current) {
|
||||
bottomRef.current.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}, [lines.length, userScrolled]);
|
||||
|
||||
// Detect user scroll
|
||||
const handleScroll = () => {
|
||||
if (!containerRef.current) return;
|
||||
const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
|
||||
const isAtBottom = scrollHeight - scrollTop - clientHeight < 20;
|
||||
setUserScrolled(!isAtBottom);
|
||||
};
|
||||
|
||||
// Show only the most recent lines
|
||||
const visibleLines = lines.slice(-maxLines);
|
||||
|
||||
return (
|
||||
<div className="ls-terminal" ref={containerRef} onScroll={handleScroll}>
|
||||
{visibleLines.map((line, i) => (
|
||||
<StatusLine key={i} line={line} />
|
||||
))}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TerminalView;
|
||||
57
plugin/webapp/src/index.tsx
Normal file
57
plugin/webapp/src/index.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { PluginRegistry, WebSocketPayload, LiveStatusData } from './types';
|
||||
import LiveStatusPost from './components/live_status_post';
|
||||
import './styles/live_status.css';
|
||||
|
||||
const PLUGIN_ID = 'com.openclaw.livestatus';
|
||||
const WS_EVENT = `custom_${PLUGIN_ID}_update`;
|
||||
|
||||
class LiveStatusPlugin {
|
||||
private postTypeComponentId: string | null = null;
|
||||
|
||||
initialize(registry: PluginRegistry, store: any): void {
|
||||
// Register custom post type renderer
|
||||
this.postTypeComponentId = registry.registerPostTypeComponent(
|
||||
'custom_livestatus',
|
||||
LiveStatusPost,
|
||||
);
|
||||
|
||||
// Register WebSocket event handler
|
||||
registry.registerWebSocketEventHandler(WS_EVENT, (msg: any) => {
|
||||
const data = msg.data as WebSocketPayload;
|
||||
if (!data || !data.post_id) return;
|
||||
|
||||
// Update global store
|
||||
const update: LiveStatusData = {
|
||||
session_key: data.session_key,
|
||||
post_id: data.post_id,
|
||||
agent_id: data.agent_id,
|
||||
status: data.status as LiveStatusData['status'],
|
||||
lines: data.lines || [],
|
||||
elapsed_ms: data.elapsed_ms || 0,
|
||||
token_count: data.token_count || 0,
|
||||
children: data.children || [],
|
||||
start_time_ms: data.start_time_ms || 0,
|
||||
};
|
||||
|
||||
window.__livestatus_updates[data.post_id] = update;
|
||||
|
||||
// Notify listeners
|
||||
const listeners = window.__livestatus_listeners[data.post_id];
|
||||
if (listeners) {
|
||||
listeners.forEach((fn) => fn(update));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
uninitialize(): void {
|
||||
// Cleanup handled by Mattermost plugin framework
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize global stores
|
||||
if (typeof window !== 'undefined') {
|
||||
window.__livestatus_updates = window.__livestatus_updates || {};
|
||||
window.__livestatus_listeners = window.__livestatus_listeners || {};
|
||||
}
|
||||
|
||||
(window as any).registerPlugin(PLUGIN_ID, new LiveStatusPlugin());
|
||||
197
plugin/webapp/src/styles/live_status.css
Normal file
197
plugin/webapp/src/styles/live_status.css
Normal file
@@ -0,0 +1,197 @@
|
||||
/* OpenClaw Live Status — Post Type Styles */
|
||||
|
||||
.ls-post {
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.ls-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: var(--center-channel-bg, #fff);
|
||||
border-bottom: 1px solid var(--center-channel-color-08, rgba(0, 0, 0, 0.08));
|
||||
}
|
||||
|
||||
.ls-agent-badge {
|
||||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
background: var(--button-bg, #166de0);
|
||||
color: var(--button-color, #fff);
|
||||
}
|
||||
|
||||
.ls-child-badge {
|
||||
font-size: 11px;
|
||||
background: var(--center-channel-color-24, rgba(0, 0, 0, 0.24));
|
||||
}
|
||||
|
||||
.ls-status-badge {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.ls-status-active .ls-status-badge {
|
||||
background: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.ls-status-done .ls-status-badge {
|
||||
background: #e3f2fd;
|
||||
color: #1565c0;
|
||||
}
|
||||
|
||||
.ls-status-error .ls-status-badge {
|
||||
background: #fbe9e7;
|
||||
color: #c62828;
|
||||
}
|
||||
|
||||
.ls-status-interrupted .ls-status-badge {
|
||||
background: #fff3e0;
|
||||
color: #e65100;
|
||||
}
|
||||
|
||||
.ls-elapsed {
|
||||
font-size: 12px;
|
||||
color: var(--center-channel-color-56, rgba(0, 0, 0, 0.56));
|
||||
margin-left: auto;
|
||||
font-family: 'SFMono-Regular', Consolas, monospace;
|
||||
}
|
||||
|
||||
/* Live dot — pulsing green indicator */
|
||||
.ls-live-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #4caf50;
|
||||
animation: ls-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes ls-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
/* Terminal view */
|
||||
.ls-terminal {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
padding: 8px 12px;
|
||||
background: var(--center-channel-color-04, rgba(0, 0, 0, 0.04));
|
||||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Status lines */
|
||||
.ls-status-line {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.ls-tool-call {
|
||||
color: var(--center-channel-color, #3d3c40);
|
||||
}
|
||||
|
||||
.ls-tool-name {
|
||||
color: var(--link-color, #2389d7);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ls-tool-args {
|
||||
color: var(--center-channel-color-72, rgba(0, 0, 0, 0.72));
|
||||
}
|
||||
|
||||
.ls-marker-ok {
|
||||
color: #4caf50;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ls-marker-err {
|
||||
color: #f44336;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ls-thinking {
|
||||
color: var(--center-channel-color-48, rgba(0, 0, 0, 0.48));
|
||||
}
|
||||
|
||||
.ls-thinking-prefix {
|
||||
color: var(--center-channel-color-24, rgba(0, 0, 0, 0.24));
|
||||
}
|
||||
|
||||
/* Children (sub-agents) */
|
||||
.ls-children {
|
||||
border-top: 1px solid var(--center-channel-color-08, rgba(0, 0, 0, 0.08));
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.ls-child {
|
||||
margin: 0 12px;
|
||||
border-left: 2px solid var(--center-channel-color-16, rgba(0, 0, 0, 0.16));
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.ls-child-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ls-expand-icon {
|
||||
font-size: 10px;
|
||||
color: var(--center-channel-color-48, rgba(0, 0, 0, 0.48));
|
||||
width: 12px;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.ls-footer {
|
||||
padding: 4px 12px 8px;
|
||||
font-size: 11px;
|
||||
color: var(--center-channel-color-48, rgba(0, 0, 0, 0.48));
|
||||
font-family: 'SFMono-Regular', Consolas, monospace;
|
||||
}
|
||||
|
||||
/* Loading state */
|
||||
.ls-loading {
|
||||
padding: 12px;
|
||||
color: var(--center-channel-color-48, rgba(0, 0, 0, 0.48));
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
.ls-terminal::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.ls-terminal::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.ls-terminal::-webkit-scrollbar-thumb {
|
||||
background: var(--center-channel-color-16, rgba(0, 0, 0, 0.16));
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.ls-terminal::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--center-channel-color-32, rgba(0, 0, 0, 0.32));
|
||||
}
|
||||
39
plugin/webapp/src/types.ts
Normal file
39
plugin/webapp/src/types.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
export interface LiveStatusData {
|
||||
session_key: string;
|
||||
post_id: string;
|
||||
agent_id: string;
|
||||
status: 'active' | 'done' | 'error' | 'interrupted';
|
||||
lines: string[];
|
||||
elapsed_ms: number;
|
||||
token_count: number;
|
||||
children: LiveStatusData[];
|
||||
start_time_ms: number;
|
||||
}
|
||||
|
||||
export interface WebSocketPayload {
|
||||
post_id: string;
|
||||
session_key: string;
|
||||
agent_id: string;
|
||||
status: string;
|
||||
lines: string[];
|
||||
elapsed_ms: number;
|
||||
token_count: number;
|
||||
children: LiveStatusData[];
|
||||
start_time_ms: number;
|
||||
}
|
||||
|
||||
// Mattermost plugin registry types (subset)
|
||||
export interface PluginRegistry {
|
||||
registerPostTypeComponent(typeName: string, component: any): string;
|
||||
registerWebSocketEventHandler(event: string, handler: (msg: any) => void): void;
|
||||
registerReducer(reducer: any): void;
|
||||
unregisterComponent(componentId: string): void;
|
||||
}
|
||||
|
||||
export interface Post {
|
||||
id: string;
|
||||
type: string;
|
||||
props: Record<string, any>;
|
||||
channel_id: string;
|
||||
root_id: string;
|
||||
}
|
||||
18
plugin/webapp/tsconfig.json
Normal file
18
plugin/webapp/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES6",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "node",
|
||||
"jsx": "react",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"outDir": "./dist",
|
||||
"declaration": false,
|
||||
"sourceMap": false,
|
||||
"lib": ["ES6", "DOM"]
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
32
plugin/webapp/webpack.config.js
Normal file
32
plugin/webapp/webpack.config.js
Normal file
@@ -0,0 +1,32 @@
|
||||
var path = require('path');
|
||||
|
||||
module.exports = {
|
||||
entry: './src/index.tsx',
|
||||
output: {
|
||||
path: path.resolve(__dirname, 'dist'),
|
||||
filename: 'main.js',
|
||||
library: {
|
||||
type: 'umd',
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
extensions: ['.ts', '.tsx', '.js', '.jsx'],
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.tsx?$/,
|
||||
use: 'ts-loader',
|
||||
exclude: /node_modules/,
|
||||
},
|
||||
{
|
||||
test: /\.css$/,
|
||||
use: ['style-loader', 'css-loader'],
|
||||
},
|
||||
],
|
||||
},
|
||||
externals: {
|
||||
react: 'React',
|
||||
'react-dom': 'ReactDOM',
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user