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

@@ -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] -->