task panel

This commit is contained in:
2026-02-19 09:43:01 +03:00
parent a3a9f0788d
commit d29bc511a2
15 changed files with 57271 additions and 59119 deletions

View File

@@ -1,196 +1,207 @@
<!-- [DEF:LogEntryRow:Component] -->
<!-- @SEMANTICS: log, entry, row, ui, svelte -->
<!-- @PURPOSE: Optimized row rendering for a single log entry with color coding and progress bar support. -->
<!-- @TIER: STANDARD -->
<!-- @LAYER: UI -->
<!-- @UX_STATE: Idle -> (displays log entry) -->
<!--
@TIER: STANDARD
@SEMANTICS: log, entry, row, ui
@PURPOSE: Renders a single log entry with stacked layout optimized for narrow drawer panels.
@LAYER: UI
@UX_STATE: Idle -> Displays log entry with color-coded level and source badges.
-->
<script>
/** @type {Object} log - The log entry object */
export let log;
/** @type {boolean} showSource - Whether to show the source tag */
export let showSource = true;
// Format timestamp for display
// [DEF:formatTime:Function]
/** @PURPOSE Format ISO timestamp to HH:MM:SS */
$: formattedTime = formatTime(log.timestamp);
function formatTime(timestamp) {
if (!timestamp) return '';
if (!timestamp) return "";
const date = new Date(timestamp);
return date.toLocaleTimeString('en-US', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
return date.toLocaleTimeString("en-US", {
hour12: false,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
// [/DEF:formatTime:Function]
// Get level class for styling
$: levelClass = getLevelClass(log.level);
function getLevelClass(level) {
switch (level?.toUpperCase()) {
case 'DEBUG': return 'level-debug';
case 'INFO': return 'level-info';
case 'WARNING': return 'level-warning';
case 'ERROR': return 'level-error';
default: return 'level-info';
case "DEBUG":
return "level-debug";
case "INFO":
return "level-info";
case "WARNING":
return "level-warning";
case "ERROR":
return "level-error";
default:
return "level-info";
}
}
// Get source class for styling
$: sourceClass = getSourceClass(log.source);
function getSourceClass(source) {
if (!source) return 'source-default';
return `source-${source.toLowerCase().replace(/[^a-z0-9]/g, '-')}`;
if (!source) return "source-default";
return `source-${source.toLowerCase().replace(/[^a-z0-9]/g, "-")}`;
}
// Check if log has progress metadata
$: hasProgress = log.metadata?.progress !== undefined;
$: progressPercent = log.metadata?.progress || 0;
</script>
<div class="log-entry-row {levelClass}" class:has-progress={hasProgress}>
<span class="log-time">{formattedTime}</span>
<span class="log-level {levelClass}">{log.level || 'INFO'}</span>
{#if showSource}
<span class="log-source {sourceClass}">{log.source || 'system'}</span>
{/if}
<span class="log-message">
{log.message}
{#if hasProgress}
<div class="progress-bar-container">
<div class="progress-bar" style="width: {progressPercent}%"></div>
<span class="progress-text">{progressPercent.toFixed(0)}%</span>
</div>
<div class="log-row">
<!-- Meta line: time + level + source -->
<div class="log-meta">
<span class="log-time">{formattedTime}</span>
<span class="log-level {levelClass}">{log.level || "INFO"}</span>
{#if showSource && log.source}
<span class="log-source {sourceClass}">{log.source}</span>
{/if}
</span>
</div>
<!-- Message -->
<div class="log-message">{log.message}</div>
<!-- Progress bar (if applicable) -->
{#if hasProgress}
<div class="progress-container">
<div class="progress-track">
<div class="progress-fill" style="width: {progressPercent}%"></div>
</div>
<span class="progress-text">{progressPercent.toFixed(0)}%</span>
</div>
{/if}
</div>
<!-- [/DEF:LogEntryRow:Component] -->
<style>
.log-entry-row {
display: grid;
grid-template-columns: 80px 70px auto 1fr;
gap: 0.75rem;
padding: 0.375rem 0.75rem;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 0.8125rem;
border-bottom: 1px solid #1e293b;
align-items: start;
.log-row {
padding: 0.5rem 0.75rem;
border-bottom: 1px solid rgba(30, 41, 59, 0.6);
transition: background-color 0.1s;
}
.log-entry-row.has-progress {
grid-template-columns: 80px 70px auto 1fr;
}
.log-entry-row:hover {
.log-row:hover {
background-color: rgba(30, 41, 59, 0.5);
}
/* Alternating row backgrounds handled by parent */
.log-meta {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.25rem;
}
.log-time {
color: #64748b;
font-size: 0.75rem;
white-space: nowrap;
font-family: "JetBrains Mono", "Fira Code", monospace;
font-size: 0.6875rem;
color: #475569;
flex-shrink: 0;
}
.log-level {
font-family: "JetBrains Mono", "Fira Code", monospace;
font-weight: 600;
text-transform: uppercase;
font-size: 0.6875rem;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
text-align: center;
font-size: 0.625rem;
padding: 0.0625rem 0.375rem;
border-radius: 0.1875rem;
letter-spacing: 0.03em;
flex-shrink: 0;
}
.level-debug {
color: #64748b;
background-color: rgba(100, 116, 139, 0.2);
background-color: rgba(100, 116, 139, 0.15);
}
.level-info {
color: #3b82f6;
background-color: rgba(59, 130, 246, 0.15);
color: #38bdf8;
background-color: rgba(56, 189, 248, 0.1);
}
.level-warning {
color: #f59e0b;
background-color: rgba(245, 158, 11, 0.15);
color: #fbbf24;
background-color: rgba(251, 191, 36, 0.1);
}
.level-error {
color: #ef4444;
background-color: rgba(239, 68, 68, 0.15);
color: #f87171;
background-color: rgba(248, 113, 113, 0.1);
}
.log-source {
font-size: 0.6875rem;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
background-color: rgba(100, 116, 139, 0.2);
color: #94a3b8;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
max-width: 120px;
font-size: 0.625rem;
padding: 0.0625rem 0.375rem;
border-radius: 0.1875rem;
background-color: rgba(100, 116, 139, 0.15);
color: #64748b;
flex-shrink: 0;
}
.source-plugin {
background-color: rgba(34, 197, 94, 0.15);
color: #22c55e;
background-color: rgba(34, 197, 94, 0.1);
color: #4ade80;
}
.source-superset-api, .source-superset_api {
background-color: rgba(168, 85, 247, 0.15);
color: #a855f7;
.source-superset-api,
.source-superset_api {
background-color: rgba(168, 85, 247, 0.1);
color: #c084fc;
}
.source-git {
background-color: rgba(249, 115, 22, 0.15);
color: #f97316;
background-color: rgba(249, 115, 22, 0.1);
color: #fb923c;
}
.source-system {
background-color: rgba(59, 130, 246, 0.15);
color: #3b82f6;
background-color: rgba(56, 189, 248, 0.1);
color: #38bdf8;
}
.log-message {
color: #e2e8f0;
font-family: "JetBrains Mono", "Fira Code", monospace;
font-size: 0.8125rem;
line-height: 1.5;
color: #cbd5e1;
word-break: break-word;
white-space: pre-wrap;
}
.progress-bar-container {
.progress-container {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.25rem;
background-color: #1e293b;
border-radius: 0.25rem;
overflow: hidden;
height: 1rem;
margin-top: 0.375rem;
}
.progress-bar {
background: linear-gradient(90deg, #3b82f6, #8b5cf6);
.progress-track {
flex: 1;
height: 0.375rem;
background-color: #1e293b;
border-radius: 9999px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #3b82f6, #8b5cf6);
border-radius: 9999px;
transition: width 0.3s ease;
}
.progress-text {
font-family: "JetBrains Mono", "Fira Code", monospace;
font-size: 0.625rem;
color: #94a3b8;
padding: 0 0.25rem;
position: absolute;
right: 0.25rem;
}
.progress-bar-container {
position: relative;
color: #64748b;
flex-shrink: 0;
}
</style>
<!-- [/DEF:LogEntryRow:Component] -->

View File

@@ -1,161 +1,234 @@
<!-- [DEF:LogFilterBar:Component] -->
<!-- @SEMANTICS: log, filter, ui, svelte -->
<!-- @PURPOSE: UI component for filtering logs by level, source, and text search. -->
<!-- @TIER: STANDARD -->
<!-- @LAYER: UI -->
<!-- @UX_STATE: Idle -> FilterChanged -> (parent applies filter) -->
<!--
@TIER: STANDARD
@SEMANTICS: log, filter, ui
@PURPOSE: Compact filter toolbar for logs — level, source, and text search in a single dense row.
@LAYER: UI
@UX_STATE: Idle -> Shows filter controls
@UX_STATE: Active -> Filters applied, clear button visible
-->
<script>
import { createEventDispatcher } from 'svelte';
import { createEventDispatcher } from "svelte";
// Props
/** @type {string[]} availableSources - List of available source options */
export let availableSources = [];
/** @type {string} selectedLevel - Currently selected log level filter */
export let selectedLevel = '';
export let selectedLevel = "";
/** @type {string} selectedSource - Currently selected source filter */
export let selectedSource = '';
export let selectedSource = "";
/** @type {string} searchText - Current search text */
export let searchText = '';
export let searchText = "";
const dispatch = createEventDispatcher();
// Log level options
const levelOptions = [
{ value: '', label: 'All Levels' },
{ value: 'DEBUG', label: 'Debug' },
{ value: 'INFO', label: 'Info' },
{ value: 'WARNING', label: 'Warning' },
{ value: 'ERROR', label: 'Error' }
{ value: "", label: "All" },
{ value: "DEBUG", label: "Debug" },
{ value: "INFO", label: "Info" },
{ value: "WARNING", label: "Warn" },
{ value: "ERROR", label: "Error" },
];
// Handle filter changes
function handleLevelChange(event) {
selectedLevel = event.target.value;
dispatch('filter-change', { level: selectedLevel, source: selectedSource, search: searchText });
dispatch("filter-change", {
level: selectedLevel,
source: selectedSource,
search: searchText,
});
}
function handleSourceChange(event) {
selectedSource = event.target.value;
dispatch('filter-change', { level: selectedLevel, source: selectedSource, search: searchText });
dispatch("filter-change", {
level: selectedLevel,
source: selectedSource,
search: searchText,
});
}
function handleSearchChange(event) {
searchText = event.target.value;
dispatch('filter-change', { level: selectedLevel, source: selectedSource, search: searchText });
dispatch("filter-change", {
level: selectedLevel,
source: selectedSource,
search: searchText,
});
}
function clearFilters() {
selectedLevel = '';
selectedSource = '';
searchText = '';
dispatch('filter-change', { level: '', source: '', search: '' });
selectedLevel = "";
selectedSource = "";
searchText = "";
dispatch("filter-change", { level: "", source: "", search: "" });
}
$: hasActiveFilters = selectedLevel || selectedSource || searchText;
</script>
<div class="log-filter-bar">
<div class="filter-group">
<label for="level-filter" class="filter-label">Level:</label>
<select id="level-filter" class="filter-select" value={selectedLevel} on:change={handleLevelChange}>
<div class="filter-bar">
<div class="filter-controls">
<select
class="filter-select"
value={selectedLevel}
on:change={handleLevelChange}
aria-label="Filter by level"
>
{#each levelOptions as option}
<option value={option.value}>{option.label}</option>
{/each}
</select>
</div>
<div class="filter-group">
<label for="source-filter" class="filter-label">Source:</label>
<select id="source-filter" class="filter-select" value={selectedSource} on:change={handleSourceChange}>
<select
class="filter-select"
value={selectedSource}
on:change={handleSourceChange}
aria-label="Filter by source"
>
<option value="">All Sources</option>
{#each availableSources as source}
<option value={source}>{source}</option>
{/each}
</select>
<div class="search-wrapper">
<svg
class="search-icon"
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="11" cy="11" r="8" />
<path d="M21 21l-4.35-4.35" />
</svg>
<input
type="text"
class="search-input"
placeholder="Search..."
value={searchText}
on:input={handleSearchChange}
aria-label="Search logs"
/>
</div>
</div>
<div class="filter-group search-group">
<label for="search-filter" class="filter-label">Search:</label>
<input
id="search-filter"
type="text"
class="filter-input"
placeholder="Search logs..."
value={searchText}
on:input={handleSearchChange}
/>
</div>
{#if selectedLevel || selectedSource || searchText}
<button class="clear-btn" on:click={clearFilters}>
Clear Filters
{#if hasActiveFilters}
<button
class="clear-btn"
on:click={clearFilters}
aria-label="Clear filters"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
{/if}
</div>
<!-- [/DEF:LogFilterBar:Component] -->
<style>
.log-filter-bar {
display: flex;
flex-wrap: wrap;
gap: 1rem;
align-items: center;
padding: 0.75rem;
background-color: #1e293b;
border-radius: 0.5rem;
margin-bottom: 0.5rem;
}
.filter-group {
.filter-bar {
display: flex;
align-items: center;
gap: 0.5rem;
}
.filter-label {
font-size: 0.875rem;
color: #94a3b8;
font-weight: 500;
}
.filter-select, .filter-input {
background-color: #334155;
color: #e2e8f0;
border: 1px solid #475569;
border-radius: 0.375rem;
gap: 0.375rem;
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
min-width: 120px;
background-color: #0f172a;
border-bottom: 1px solid #1e293b;
}
.filter-select:focus, .filter-input:focus {
.filter-controls {
display: flex;
align-items: center;
gap: 0.375rem;
flex: 1;
min-width: 0;
}
.filter-select {
background-color: #1e293b;
color: #94a3b8;
border: 1px solid #334155;
border-radius: 0.25rem;
padding: 0.3125rem 0.5rem;
font-size: 0.75rem;
cursor: pointer;
flex-shrink: 0;
appearance: none;
-webkit-appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2364748b' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.375rem center;
padding-right: 1.5rem;
}
.filter-select:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
}
.search-group {
.search-wrapper {
position: relative;
flex: 1;
min-width: 200px;
min-width: 0;
}
.filter-input {
.search-icon {
position: absolute;
left: 0.5rem;
top: 50%;
transform: translateY(-50%);
color: #475569;
pointer-events: none;
}
.search-input {
width: 100%;
max-width: 300px;
background-color: #1e293b;
color: #e2e8f0;
border: 1px solid #334155;
border-radius: 0.25rem;
padding: 0.3125rem 0.5rem 0.3125rem 1.75rem;
font-size: 0.75rem;
}
.search-input::placeholder {
color: #475569;
}
.search-input:focus {
outline: none;
border-color: #3b82f6;
}
.clear-btn {
background-color: #475569;
color: #e2e8f0;
border: none;
border-radius: 0.375rem;
padding: 0.5rem 1rem;
font-size: 0.875rem;
display: flex;
align-items: center;
justify-content: center;
padding: 0.3125rem;
background: none;
border: 1px solid #334155;
border-radius: 0.25rem;
color: #64748b;
cursor: pointer;
transition: background-color 0.2s;
flex-shrink: 0;
transition: all 0.15s;
}
.clear-btn:hover {
background-color: #64748b;
color: #f87171;
border-color: #f87171;
background-color: rgba(248, 113, 113, 0.1);
}
</style>
<!-- [/DEF:LogFilterBar:Component] -->

View File

@@ -2,58 +2,82 @@
<!--
@TIER: STANDARD
@SEMANTICS: task, log, panel, filter, list
@PURPOSE: Combines log filtering and display into a single cohesive panel.
@PURPOSE: Combines log filtering and display into a single cohesive dark-themed panel.
@LAYER: UI
@RELATION: USES -> frontend/src/components/tasks/LogFilterBar.svelte
@RELATION: USES -> frontend/src/components/tasks/LogEntryRow.svelte
@INVARIANT: Must always display logs in chronological order and respect auto-scroll preference.
@UX_STATE: Empty -> Displays "No logs" message
@UX_STATE: Populated -> Displays list of LogEntryRow components
@UX_STATE: AutoScroll -> Automatically scrolls to bottom on new logs
-->
<script>
import { createEventDispatcher, onMount, afterUpdate } from 'svelte';
import LogFilterBar from './LogFilterBar.svelte';
import LogEntryRow from './LogEntryRow.svelte';
import { createEventDispatcher, onMount, afterUpdate } from "svelte";
import LogFilterBar from "./LogFilterBar.svelte";
import LogEntryRow from "./LogEntryRow.svelte";
/**
* @PURPOSE: Component properties and state.
* @PRE: taskId is a valid string, logs is an array of LogEntry objects.
* @UX_STATE: [Empty] -> Displays "No logs available" message.
* @UX_STATE: [Populated] -> Displays list of LogEntryRow components.
* @UX_STATE: [AutoScroll] -> Automatically scrolls to bottom on new logs.
* @PURPOSE Component properties and state.
* @PRE taskId is a valid string, logs is an array of LogEntry objects.
*/
export let taskId = '';
export let taskId = "";
export let logs = [];
export let autoScroll = true;
const dispatch = createEventDispatcher();
let scrollContainer;
let selectedSource = 'all';
let selectedLevel = 'all';
let selectedSource = "all";
let selectedLevel = "all";
let searchText = "";
/**
* @PURPOSE: Handles filter changes from LogFilterBar.
* @PRE: event.detail contains source and level.
* @POST: Dispatches filterChange event to parent.
* @SIDE_EFFECT: Updates local filter state.
*/
function handleFilterChange(event) {
const { source, level } = event.detail;
selectedSource = source;
selectedLevel = level;
console.log(`[TaskLogPanel][STATE] Filter changed: source=${source}, level=${level}`);
dispatch('filterChange', { source, level });
// Filtered logs based on current filters
$: filteredLogs = filterLogs(logs, selectedLevel, selectedSource, searchText);
function filterLogs(allLogs, level, source, search) {
return allLogs.filter((log) => {
if (
level &&
level !== "all" &&
log.level?.toUpperCase() !== level.toUpperCase()
)
return false;
if (
source &&
source !== "all" &&
log.source?.toLowerCase() !== source.toLowerCase()
)
return false;
if (search && !log.message?.toLowerCase().includes(search.toLowerCase()))
return false;
return true;
});
}
// Extract unique sources from logs
$: availableSources = [...new Set(logs.map((l) => l.source).filter(Boolean))];
function handleFilterChange(event) {
const { source, level, search } = event.detail;
selectedSource = source || "all";
selectedLevel = level || "all";
searchText = search || "";
console.log(
`[TaskLogPanel][Action] Filter: level=${selectedLevel}, source=${selectedSource}, search=${searchText}`,
);
dispatch("filterChange", { source, level });
}
/**
* @PURPOSE: Scrolls the log container to the bottom.
* @PRE: autoScroll is true and scrollContainer is bound.
* @POST: scrollContainer.scrollTop is set to scrollHeight.
*/
function scrollToBottom() {
if (autoScroll && scrollContainer) {
scrollContainer.scrollTop = scrollContainer.scrollHeight;
}
}
function toggleAutoScroll() {
autoScroll = !autoScroll;
if (autoScroll) scrollToBottom();
}
afterUpdate(() => {
scrollToBottom();
});
@@ -63,57 +87,162 @@
});
</script>
<div class="flex flex-col h-full bg-gray-900 text-gray-100 rounded-lg overflow-hidden border border-gray-700">
<!-- Header / Filter Bar -->
<div class="p-2 bg-gray-800 border-b border-gray-700">
<LogFilterBar
{taskId}
on:filter={handleFilterChange}
/>
</div>
<div class="log-panel">
<!-- Filter Bar -->
<LogFilterBar {availableSources} on:filter-change={handleFilterChange} />
<!-- Log List -->
<div
bind:this={scrollContainer}
class="flex-1 overflow-y-auto p-2 font-mono text-sm space-y-0.5"
>
{#if logs.length === 0}
<div class="text-gray-500 italic text-center py-4">
No logs available for this task.
<div bind:this={scrollContainer} class="log-list">
{#if filteredLogs.length === 0}
<div class="empty-logs">
<svg
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10 9 9 9 8 9" />
</svg>
<span>No logs available</span>
</div>
{:else}
{#each logs as log}
{#each filteredLogs as log}
<LogEntryRow {log} />
{/each}
{/if}
</div>
<!-- Footer / Stats -->
<div class="px-3 py-1 bg-gray-800 border-t border-gray-700 text-xs text-gray-400 flex justify-between items-center">
<span>Total: {logs.length} entries</span>
{#if autoScroll}
<span class="text-green-500 flex items-center gap-1">
<span class="w-2 h-2 bg-green-500 rounded-full animate-pulse"></span>
Auto-scroll active
</span>
{/if}
<!-- Footer Stats -->
<div class="log-footer">
<span class="log-count">
{filteredLogs.length}{filteredLogs.length !== logs.length
? ` / ${logs.length}`
: ""} entries
</span>
<button
class="autoscroll-btn"
class:active={autoScroll}
on:click={toggleAutoScroll}
aria-label="Toggle auto-scroll"
>
{#if autoScroll}
<span class="pulse-dot"></span>
{/if}
Auto-scroll {autoScroll ? "on" : "off"}
</button>
</div>
</div>
<!-- [/DEF:TaskLogPanel:Component] -->
<style>
/* Custom scrollbar for the log container */
div::-webkit-scrollbar {
width: 8px;
.log-panel {
display: flex;
flex-direction: column;
height: 100%;
background-color: #0f172a;
overflow: hidden;
}
div::-webkit-scrollbar-track {
background: #1f2937;
.log-list {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
}
div::-webkit-scrollbar-thumb {
background: #4b5563;
border-radius: 4px;
/* Custom scrollbar */
.log-list::-webkit-scrollbar {
width: 6px;
}
div::-webkit-scrollbar-thumb:hover {
background: #6b7280;
.log-list::-webkit-scrollbar-track {
background: #0f172a;
}
.log-list::-webkit-scrollbar-thumb {
background: #334155;
border-radius: 3px;
}
.log-list::-webkit-scrollbar-thumb:hover {
background: #475569;
}
.empty-logs {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem 1rem;
color: #334155;
gap: 0.75rem;
}
.empty-logs span {
font-size: 0.8125rem;
color: #475569;
}
.log-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.375rem 0.75rem;
border-top: 1px solid #1e293b;
background-color: #0f172a;
}
.log-count {
font-family: "JetBrains Mono", "Fira Code", monospace;
font-size: 0.6875rem;
color: #475569;
}
.autoscroll-btn {
display: flex;
align-items: center;
gap: 0.375rem;
background: none;
border: none;
color: #475569;
font-size: 0.6875rem;
cursor: pointer;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
transition: all 0.15s;
}
.autoscroll-btn:hover {
background-color: #1e293b;
color: #94a3b8;
}
.autoscroll-btn.active {
color: #22d3ee;
}
.pulse-dot {
display: inline-block;
width: 5px;
height: 5px;
border-radius: 50%;
background-color: #22d3ee;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.3;
}
}
</style>
<!-- [/DEF:TaskLogPanel:Component] -->