1
0
Fork 0
This commit is contained in:
Pabloader 2026-11-03 07:36:12 +00:00
parent 97533daa91
commit a6a189de1d
6 changed files with 290 additions and 158 deletions

View File

@ -52,8 +52,14 @@ export const importStoredReducer = <T>(key: string): boolean => {
return true; return true;
}; };
export const useRemoteReducer = <T, A>(key: string, reducer: Reducer<T, A>, initialValue: T) => { export interface RemoteReducerOptions {
/** Skip persisting changes while true; the latest state is saved when it turns false again. */
paused?: boolean;
}
export const useRemoteReducer = <T, A>(key: string, reducer: Reducer<T, A>, initialValue: T, options: RemoteReducerOptions = {}) => {
const storedKey = remoteReducerKey(key); const storedKey = remoteReducerKey(key);
const paused = options.paused ?? false;
const getInitialValue = (): T => { const getInitialValue = (): T => {
try { try {
@ -73,8 +79,9 @@ export const useRemoteReducer = <T, A>(key: string, reducer: Reducer<T, A>, init
}, [storedKey]); }, [storedKey]);
useEffect(() => { useEffect(() => {
if (paused) return;
saveObject(storedKey, state); saveObject(storedKey, state);
}, [storedKey, state]); }, [storedKey, state, paused]);
return [state, dispatch] as [T, Dispatch<A>]; return [state, dispatch] as [T, Dispatch<A>];
}; };

View File

@ -4,6 +4,8 @@ import { useInputState } from "@common/hooks/useInputState";
import { useMediaQuery } from "@common/hooks/useMediaQuery"; import { useMediaQuery } from "@common/hooks/useMediaQuery";
import clsx from "clsx"; import clsx from "clsx";
import { Check, ChevronsRight, Edit2, GitFork, RefreshCw, SendHorizonal, Sparkles, Square, Trash2, X } from "lucide-preact"; import { Check, ChevronsRight, Edit2, GitFork, RefreshCw, SendHorizonal, Sparkles, Square, Trash2, X } from "lucide-preact";
import { Component } from "preact";
import type { ComponentType } from "preact";
import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks"; import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks";
import { MOBILE_BREAKPOINT } from '../assets/breakpoints'; import { MOBILE_BREAKPOINT } from '../assets/breakpoints';
import styles from '../assets/chat-sidebar.module.css'; import styles from '../assets/chat-sidebar.module.css';
@ -15,55 +17,193 @@ import { Tools } from "../utils/tools";
import { useChapterSummarization } from "../utils/useChapterSummarization"; import { useChapterSummarization } from "../utils/useChapterSummarization";
import { Sidebar } from "./sidebar"; import { Sidebar } from "./sidebar";
/** Minimal `memo` for preact core (avoids pulling in the whole `preact/compat` layer). */
function memo<P extends object>(Render: ComponentType<P>) {
return class Memoized extends Component<P> {
override shouldComponentUpdate(nextProps: P) {
const prevProps = this.props as Record<string, unknown>;
const next = nextProps as Record<string, unknown>;
const prevKeys = Object.keys(prevProps);
const nextKeys = Object.keys(next);
if (prevKeys.length !== nextKeys.length) return true;
return nextKeys.some(key => prevProps[key] !== next[key]);
}
interface RoleHeaderProps { override render() {
message: ChatMessage; return <Render {...this.props} />;
chatMessages: ChatMessage[]; }
};
} }
const RoleHeader = ({ message, chatMessages }: RoleHeaderProps) => { interface RoleHeaderProps {
const { currentWorld, userName } = useAppState(); displayName: string;
roleLabel: string | null;
const toolName = useMemo(() => { }
if (message.role !== 'tool') return;
for (const m of chatMessages.toReversed()) {
if (m.role !== 'assistant') continue;
const toolCall = m.tool_calls?.find(tc => tc.id === message.tool_call_id);
if (toolCall) return toolCall.function.name;
}
}, [message, chatMessages]);
let displayName: string = message.role;
let roleLabel = null;
if (message.role === 'tool' && toolName) {
displayName = toolName;
roleLabel = message.role;
} else if (currentWorld?.chatOnly) {
if (message.role === 'user' && userName) {
displayName = userName;
roleLabel = message.role;
} else if (message.role === 'assistant' && currentWorld.title) {
displayName = currentWorld.title;
roleLabel = message.role;
}
}
const RoleHeader = ({ displayName, roleLabel }: RoleHeaderProps) => {
return ( return (
<div class={styles.role}> <div class={styles.role}>
<span>{displayName}</span> <span>{displayName}</span>
{roleLabel && ( {roleLabel && (
<span class={styles.toolBadge}> <span class={styles.toolBadge}>
{message.role} {roleLabel}
</span> </span>
)} )}
</div> </div>
); );
}; };
interface MessageRowProps {
message: ChatMessage;
displayName: string;
roleLabel: string | null;
isEditing: boolean;
isLoading: boolean;
chatOnly: boolean;
substitution: Prompt.SubstitutionVars;
editingContent: string;
onEditInput: (e: Event | string) => void;
onStartEdit: (message: ChatMessage) => void;
onCancelEdit: () => void;
onSaveEdit: (messageId: string, content: string) => void;
onDelete: (messageId: string) => void;
onFork: (messageId: string) => void;
}
const MessageRow = memo(function MessageRow({
message,
displayName,
roleLabel,
isEditing,
isLoading,
chatOnly,
substitution,
editingContent,
onEditInput,
onStartEdit,
onCancelEdit,
onSaveEdit,
onDelete,
onFork,
}: MessageRowProps) {
const canEdit = message.role === 'user' || message.role === 'assistant';
// Markdown highlighting is expensive; only redo it when this message (or the substitution vars) change.
const contentHtml = useMemo(
() => highlight(Prompt.substitute(message.content, substitution), false).trim(),
[message.content, substitution],
);
return (
<div class={styles.message} data-role={message.role}>
<div class={styles.messageHeader}>
<RoleHeader displayName={displayName} roleLabel={roleLabel} />
{!isLoading && canEdit && (
<div class={styles.messageActions}>
{isEditing ? (
<>
<button
class={clsx(styles.iconButton, styles.saveButton)}
onClick={() => onSaveEdit(message.id, editingContent)}
disabled={!editingContent.trim()}
title="Save (Ctrl+Enter)"
>
<Check size={12} />
</button>
<button
class={clsx(styles.iconButton, styles.cancelButton)}
onClick={onCancelEdit}
title="Cancel (Esc)"
>
<X size={12} />
</button>
</>
) : (
<>
{chatOnly && (
<button
class={styles.iconButton}
onClick={() => onFork(message.id)}
title="Fork chat up to this message"
>
<GitFork size={12} />
</button>
)}
<button
class={styles.iconButton}
onClick={() => onStartEdit(message)}
title="Edit message"
>
<Edit2 size={12} />
</button>
<button
class={styles.iconButton}
onClick={() => onDelete(message.id)}
title="Delete this and all following messages"
>
<Trash2 size={12} />
</button>
</>
)}
</div>
)}
</div>
{isEditing ? (
<div class={styles.editContainer}>
<ContentEditable
autoLines
highlight
class={styles.editTextarea}
value={editingContent}
onInput={onEditInput}
onKeyDown={(e) => {
if (e.key === 'Enter' && e.ctrlKey) {
e.preventDefault();
onSaveEdit(message.id, editingContent);
}
if (e.key === 'Escape') {
e.preventDefault();
onCancelEdit();
}
}}
/>
</div>
) : (
<>
{message.role === 'assistant' && message.reasoning_content?.trim() && (
<div class={styles.reasoningContent}>
{message.reasoning_content.trim()}
</div>
)}
<div
class={styles.content}
dangerouslySetInnerHTML={{ __html: contentHtml }}
/>
{message.role === 'assistant' && message.tool_calls && (
<div class={styles.toolCalls}>
{message.tool_calls.map((tool) => (
<span key={tool.id} class={styles.toolBadge}>
{tool.function.name}
</span>
))}
</div>
)}
</>
)}
</div>
);
});
export const ChatPanel = ({ visible }: { visible: boolean }) => { export const ChatPanel = ({ visible }: { visible: boolean }) => {
const appState = useAppState(); const appState = useAppState();
const { currentWorld, currentStory, dispatch, connection, model, enableThinking, chatOpen, continuePrompt } = appState; const {
currentWorld, currentStory, dispatch, connection, model, enableThinking,
chatOpen, continuePrompt, userName, chatSystemInstruction, setSavingPaused,
} = appState;
const { summarizeAll, isSummarizing } = useChapterSummarization(); const { summarizeAll, isSummarizing } = useChapterSummarization();
const [input, setInput] = useInputState(''); const [input, setInput] = useInputState('');
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@ -82,6 +222,25 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => {
const lastMessage = currentStory?.chatMessages.at(-1); const lastMessage = currentStory?.chatMessages.at(-1);
// Stable values for the memoized message rows: they must not change on every streamed chunk.
const substitution = useMemo(
() => Prompt.getSubstitutionVars(appState),
// eslint-disable-next-line react-hooks/exhaustive-deps -- exactly the fields read by getSubstitutionVars
[currentWorld?.title, userName, chatSystemInstruction],
);
// Resolve tool call ids once per message list change instead of scanning the whole history per message.
const toolNames = useMemo(() => {
const names = new Map<string, string>();
for (const message of currentStory?.chatMessages ?? []) {
if (message.role !== 'assistant') continue;
for (const toolCall of message.tool_calls ?? []) {
names.set(toolCall.id, toolCall.function.name);
}
}
return names;
}, [currentStory?.chatMessages]);
useEffect(() => { useEffect(() => {
if (messagesRef.current) { if (messagesRef.current) {
messagesRef.current.scrollTo({ messagesRef.current.scrollTo({
@ -254,7 +413,6 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => {
}); });
if (tool_calls) { if (tool_calls) {
console.log(tool_calls);
const toolMessages: ChatMessage[] = []; const toolMessages: ChatMessage[] = [];
for (const tool of tool_calls) { for (const tool of tool_calls) {
if (abortControllerRef.current?.signal.aborted) { if (abortControllerRef.current?.signal.aborted) {
@ -305,6 +463,8 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => {
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
abortControllerRef.current = new AbortController(); abortControllerRef.current = new AbortController();
// Don't persist on every streamed chunk — the final state is saved when this finishes.
setSavingPaused(true);
const excludedMessageIds = new Set<string>(); const excludedMessageIds = new Set<string>();
try { try {
@ -320,9 +480,10 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => {
} }
await sendMessage({ excludedMessageIds }); await sendMessage({ excludedMessageIds });
} finally { } finally {
setSavingPaused(false);
setIsLoading(false); setIsLoading(false);
} }
}, [currentStory, currentWorld, connection, model, isLoading, sendMessage]); }, [currentStory, currentWorld, connection, model, isLoading, sendMessage, setSavingPaused]);
const handleSendMessage = useCallback(async () => { const handleSendMessage = useCallback(async () => {
if (!currentStory || !connection || !model || isLoading) return; if (!currentStory || !connection || !model || isLoading) return;
@ -338,6 +499,7 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => {
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
abortControllerRef.current = new AbortController(); abortControllerRef.current = new AbortController();
setSavingPaused(true);
try { try {
await sendMessage({ await sendMessage({
@ -348,9 +510,10 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => {
}] }]
}); });
} finally { } finally {
setSavingPaused(false);
setIsLoading(false); setIsLoading(false);
} }
}, [currentStory, input, connection, model, isLoading, sendMessage, handleRegenerate]); }, [currentStory, input, connection, model, isLoading, sendMessage, handleRegenerate, setSavingPaused]);
const handleContinue = useCallback(async () => { const handleContinue = useCallback(async () => {
if (!currentStory || !connection || !model || isLoading) return; if (!currentStory || !connection || !model || isLoading) return;
@ -359,6 +522,7 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => {
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
abortControllerRef.current = new AbortController(); abortControllerRef.current = new AbortController();
setSavingPaused(true);
try { try {
if (currentWorld?.chatOnly) { if (currentWorld?.chatOnly) {
@ -373,9 +537,10 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => {
}); });
} }
} finally { } finally {
setSavingPaused(false);
setIsLoading(false); setIsLoading(false);
} }
}, [currentStory, currentWorld, input, connection, model, isLoading, sendMessage]); }, [currentStory, currentWorld, input, connection, model, isLoading, sendMessage, setSavingPaused]);
const handleStopGeneration = useCallback(() => { const handleStopGeneration = useCallback(() => {
abortControllerRef.current?.abort(); abortControllerRef.current?.abort();
@ -390,6 +555,7 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => {
}; };
const handleDeleteMessage = useCallback((messageId: string) => { const handleDeleteMessage = useCallback((messageId: string) => {
const { currentWorld, currentStory } = appStateRef.current;
if (!currentStory || !currentWorld) return; if (!currentStory || !currentWorld) return;
if (!confirm('Delete this and all following messages?')) return; if (!confirm('Delete this and all following messages?')) return;
dispatch({ dispatch({
@ -398,9 +564,10 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => {
storyId: currentStory.id, storyId: currentStory.id,
messageId, messageId,
}); });
}, [currentStory, currentWorld, dispatch]); }, [dispatch]);
const handleForkChat = useCallback((messageId: string) => { const handleForkChat = useCallback((messageId: string) => {
const { currentWorld, currentStory } = appStateRef.current;
if (!currentStory || !currentWorld) return; if (!currentStory || !currentWorld) return;
dispatch({ dispatch({
type: 'DUPLICATE_STORY', type: 'DUPLICATE_STORY',
@ -408,27 +575,28 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => {
id: currentStory.id, id: currentStory.id,
upToMessageId: messageId, upToMessageId: messageId,
}); });
}, [currentStory, currentWorld, dispatch]); }, [dispatch]);
const handleStartEdit = useCallback((message: ChatMessage) => { const handleStartEdit = useCallback((message: ChatMessage) => {
setEditingMessageId(message.id); setEditingMessageId(message.id);
setEditingContent(message.content); setEditingContent(message.content);
}, [setEditingContent]); }, [setEditingContent]);
const handleSaveEdit = useCallback(() => { const handleSaveEdit = useCallback((messageId: string, content: string) => {
if (!currentStory || !currentWorld || !editingMessageId) return; const { currentWorld, currentStory } = appStateRef.current;
if (!currentStory || !currentWorld || !content.trim()) return;
dispatch({ dispatch({
type: 'EDIT_CHAT_MESSAGE', type: 'EDIT_CHAT_MESSAGE',
worldId: currentWorld.id, worldId: currentWorld.id,
storyId: currentStory.id, storyId: currentStory.id,
messageId: editingMessageId, messageId,
content: editingContent.trim(), content: content.trim(),
}); });
setEditingMessageId(null); setEditingMessageId(null);
setEditingContent(''); setEditingContent('');
}, [currentStory, currentWorld, editingMessageId, editingContent, dispatch, setEditingContent]); }, [dispatch, setEditingContent]);
const handleCancelEdit = useCallback(() => { const handleCancelEdit = useCallback(() => {
setEditingMessageId(null); setEditingMessageId(null);
@ -457,109 +625,44 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => {
<div class={styles.messages} ref={messagesRef}> <div class={styles.messages} ref={messagesRef}>
{currentStory.chatMessages.map((message) => { {currentStory.chatMessages.map((message) => {
const isEditing = editingMessageId === message.id; const isEditing = editingMessageId === message.id;
const canEdit = message.role === 'user' || message.role === 'assistant'; const toolName = message.role === 'tool' && message.tool_call_id
? toolNames.get(message.tool_call_id)
: undefined;
let displayName: string = message.role;
let roleLabel: string | null = null;
if (message.role === 'tool' && toolName) {
displayName = toolName;
roleLabel = message.role;
} else if (currentWorld?.chatOnly) {
if (message.role === 'user' && userName) {
displayName = userName;
roleLabel = message.role;
} else if (message.role === 'assistant' && currentWorld.title) {
displayName = currentWorld.title;
roleLabel = message.role;
}
}
return ( return (
<div key={message.id} class={styles.message} data-role={message.role}> <MessageRow
<div class={styles.messageHeader}> key={message.id}
<RoleHeader message={message} chatMessages={currentStory.chatMessages} /> message={message}
displayName={displayName}
{!isLoading && canEdit && ( roleLabel={roleLabel}
<div class={styles.messageActions}> isEditing={isEditing}
{isEditing ? ( isLoading={isLoading}
<> chatOnly={currentWorld?.chatOnly ?? false}
<button substitution={substitution}
class={clsx(styles.iconButton, styles.saveButton)} editingContent={isEditing ? editingContent : ''}
onClick={handleSaveEdit} onEditInput={setEditingContent}
disabled={!editingContent.trim()} onStartEdit={handleStartEdit}
title="Save (Ctrl+Enter)" onCancelEdit={handleCancelEdit}
> onSaveEdit={handleSaveEdit}
<Check size={12} /> onDelete={handleDeleteMessage}
</button> onFork={handleForkChat}
<button
class={clsx(styles.iconButton, styles.cancelButton)}
onClick={handleCancelEdit}
title="Cancel (Esc)"
>
<X size={12} />
</button>
</>
) : (
<>
{currentWorld?.chatOnly && (
<button
class={styles.iconButton}
onClick={() => handleForkChat(message.id)}
title="Fork chat up to this message"
>
<GitFork size={12} />
</button>
)}
<button
class={styles.iconButton}
onClick={() => handleStartEdit(message)}
title="Edit message"
>
<Edit2 size={12} />
</button>
<button
class={styles.iconButton}
onClick={() => handleDeleteMessage(message.id)}
title="Delete this and all following messages"
>
<Trash2 size={12} />
</button>
</>
)}
</div>
)}
</div>
{isEditing ? (
<div class={styles.editContainer}>
<ContentEditable
autoLines
highlight
class={styles.editTextarea}
value={editingContent}
onInput={setEditingContent}
onKeyDown={(e) => {
if (e.key === 'Enter' && e.ctrlKey) {
e.preventDefault();
handleSaveEdit();
}
if (e.key === 'Escape') {
e.preventDefault();
handleCancelEdit();
}
}}
/> />
</div>
) : (
<>
{message.role === 'assistant' && message.reasoning_content?.trim() && (
<div class={styles.reasoningContent}>
{message.reasoning_content.trim()}
</div>
)}
<div
class={styles.content}
dangerouslySetInnerHTML={{ __html: highlight(Prompt.substituteVars(appState, message.content), false).trim() }}
/>
{message.role === 'assistant' && message.tool_calls && (
<div class={styles.toolCalls}>
{message.tool_calls.map((tool) => (
<span key={tool.id} class={styles.toolBadge}>
{tool.function.name}
</span>
))}
</div>
)}
</>
)}
</div>
); );
})} })}
{error && ( {error && (

View File

@ -67,10 +67,11 @@ export const Editor = () => {
const editorRef = useRef<HTMLDivElement>(null); const editorRef = useRef<HTMLDivElement>(null);
const promptPreview = useMemo(() => { const promptPreview = useMemo(() => {
if (currentTab !== 'prompt') return '';
let text = Prompt.formatSystemPrompt(appState); let text = Prompt.formatSystemPrompt(appState);
text = Prompt.substituteVars(appState, text); text = Prompt.substituteVars(appState, text);
return highlight(text, false); return highlight(text, false);
}, [appState]); }, [appState, currentTab]);
useEffect(() => { useEffect(() => {
if (currentStory?.lastEditedText) { if (currentStory?.lastEditedText) {

View File

@ -1,6 +1,6 @@
import { useRemoteReducer } from "@common/hooks/useRemote"; import { useRemoteReducer } from "@common/hooks/useRemote";
import { createContext } from "preact"; import { createContext } from "preact";
import { useContext, useMemo } from "preact/hooks"; import { useContext, useMemo, useState } from "preact/hooks";
import Chapters from "../utils/chapters"; import Chapters from "../utils/chapters";
import LLM from "../utils/llm"; import LLM from "../utils/llm";
@ -686,6 +686,8 @@ export interface AppState {
imageGenerationSettings: ImageGenerationSettings; imageGenerationSettings: ImageGenerationSettings;
/** Effective system instruction: world override if set, otherwise global */ /** Effective system instruction: world override if set, otherwise global */
effectiveSystemInstruction: string; effectiveSystemInstruction: string;
/** Pause persistence (used while streaming; the latest state is saved once streaming ends) */
setSavingPaused: (paused: boolean) => void;
dispatch: (action: Action) => void; dispatch: (action: Action) => void;
} }
@ -698,7 +700,8 @@ export const useAppState = () => useContext(StateContext);
export const STATE_KEY = 'state'; export const STATE_KEY = 'state';
export const StateContextProvider = ({ children }: { children?: any }) => { export const StateContextProvider = ({ children }: { children?: any }) => {
const [state, dispatch] = useRemoteReducer(STATE_KEY, reducer, DEFAULT_STATE); const [savingPaused, setSavingPaused] = useState(false);
const [state, dispatch] = useRemoteReducer(STATE_KEY, reducer, DEFAULT_STATE, { paused: savingPaused });
const value = useMemo<AppState>(() => { const value = useMemo<AppState>(() => {
const currentWorld = state.worlds.find(w => w.id === state.currentWorldId) ?? null; const currentWorld = state.worlds.find(w => w.id === state.currentWorldId) ?? null;
@ -748,6 +751,7 @@ export const StateContextProvider = ({ children }: { children?: any }) => {
? chatSystemInstruction ? chatSystemInstruction
: systemInstruction) : systemInstruction)
|| systemInstruction, || systemInstruction,
setSavingPaused,
dispatch, dispatch,
}; };
}, [state]); }, [state]);

View File

@ -299,13 +299,30 @@ namespace Prompt {
}); });
} }
export function substituteVars(state: AppState, text: string): string { /** Values used by {@link substituteVars}, extracted so they can be memoized independently of the full app state. */
const charName = state.currentWorld?.title || 'Assistant'; export interface SubstitutionVars {
const userName = state.userName || 'User'; charName: string;
userName: string;
chatSystemInstruction: string;
}
export function getSubstitutionVars(state: AppState): SubstitutionVars {
return {
charName: state.currentWorld?.title || 'Assistant',
userName: state.userName || 'User',
chatSystemInstruction: state.chatSystemInstruction,
};
}
export function substitute(text: string, vars: SubstitutionVars): string {
return text return text
.replaceAll('{{system}}', state.chatSystemInstruction) .replaceAll('{{system}}', vars.chatSystemInstruction)
.replaceAll('{{char}}', charName) .replaceAll('{{char}}', vars.charName)
.replaceAll('{{user}}', userName); .replaceAll('{{user}}', vars.userName);
}
export function substituteVars(state: AppState, text: string): string {
return substitute(text, getSubstitutionVars(state));
} }
export function formatSystemPrompt(state: AppState, storyTokenBudget: number = 0): string { export function formatSystemPrompt(state: AppState, storyTokenBudget: number = 0): string {
@ -465,13 +482,13 @@ namespace Prompt {
export function applyDelta(input: any, delta: any) { export function applyDelta(input: any, delta: any) {
if (Array.isArray(delta)) { if (Array.isArray(delta)) {
const result = [...(input ?? [])]; const result = input ?? [];
for (const item of delta) { for (const item of delta) {
result[item.index] = applyDelta(result[item.index], item); result[item.index] = applyDelta(result[item.index], item);
} }
return result; return result;
} else if (typeof delta === 'object') { } else if (typeof delta === 'object') {
const result = { ...input }; const result = input ?? {};
for (const key of Object.keys(delta)) { for (const key of Object.keys(delta)) {
const value = delta[key]; const value = delta[key];
result[key] = applyDelta(result[key], value); result[key] = applyDelta(result[key], value);

View File

@ -377,7 +377,7 @@ export namespace Tools {
cropped = true; cropped = true;
} }
dispatchEdit(currentText.trim() + '\n' + args.new_text.trim()); dispatchEdit(currentText.trim() + '\n\n' + args.new_text.trim());
appState.dispatch({ type: 'SET_CURRENT_TAB', tab }); appState.dispatch({ type: 'SET_CURRENT_TAB', tab });
let message = cropped let message = cropped
? `Added text:\n${args.new_text.split('\n').filter(l => l.trim()).map(l => '> ' + l).join('\n')}\n\nNote: The rest was cropped due to ${LINES_LIMIT} lines limit!` ? `Added text:\n${args.new_text.split('\n').filter(l => l.trim()).map(l => '> ' + l).join('\n')}\n\nNote: The rest was cropped due to ${LINES_LIMIT} lines limit!`