generated from nhcarrigan/template
feat: productivity suite — task loop, workflow, theming, docs & more (#197)
## Summary A large productivity-focused feature branch delivering a suite of improvements across automation, project management, theming, performance, and documentation. ### Features - **Guided Project Workflow** (#189) — Four-phase workflow panel (Discuss → Plan → Execute → Verify) to keep projects structured from idea to completion - **Automated Task Loop** (#179) — Per-task conversation orchestration with wave-based parallel execution, blocked-task detection, and concurrency control - **Wave-Based Parallel Execution** (#191) — Tasks run in dependency-aware waves with configurable concurrency; independent tasks execute in parallel - **Auto-Commit After Task Completion** (#192) — Task Loop optionally commits after each completed task so progress is never lost - **PRD Creator** (#180) — AI-assisted PRD and task list panel that outputs `hikari-tasks.json` for the Task Loop to consume - **Project Context Panel** (#188) — Persistent `PROJECT.md`, `REQUIREMENTS.md`, `ROADMAP.md`, and `STATE.md` files injected into Claude's context automatically - **Codebase Mapper** (#190) — Generates a `CODEBASE.md` architectural summary so Claude always understands the project structure - **Community Preset Themes** (#181) — Six built-in community themes: Dracula, Catppuccin Mocha, Nord, Solarized Dark, Gruvbox Dark, and Rosé Pine - **In-App Changelog Panel** (#193) — Fetches release notes from GitHub at runtime and displays them inside the app - **Full Embedded Documentation** (#196) — Replaced the single-page help modal with a 12-page paginated docs browser featuring a sidebar TOC, prev/next navigation, keyboard navigation (arrow keys, `?` shortcut), and comprehensive coverage of every feature ### Performance & Fixes - **Lazy Loading & Virtualisation** (#194) — Virtual windowing for conversation history, markdown memoisation, and debounced search for smooth rendering of large sessions - **Ctrl+C Copy Fix** (#195) — `Ctrl+C` now copies selected text as expected; interrupt-Claude behaviour only fires when no text is selected ### UX - Back-to-workflow button in PRD Creator and Task Loop panels for easy navigation - Navigation icon cluster replaced with a single clean dropdown menu ## Closes Closes #179 Closes #180 Closes #181 Closes #188 Closes #189 Closes #190 Closes #191 Closes #192 Closes #193 Closes #194 Closes #195 Closes #196 --- ✨ This PR was created with help from Hikari~ 🌸 Reviewed-on: #197 Co-authored-by: Hikari <hikari@nhcarrigan.com> Co-committed-by: Hikari <hikari@nhcarrigan.com>
This commit was merged in pull request #197.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { claudeStore, type TerminalLine } from "$lib/stores/claude";
|
||||
import { afterUpdate, tick, onMount, onDestroy } from "svelte";
|
||||
import { get } from "svelte/store";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
import ConversationTabs from "./ConversationTabs.svelte";
|
||||
@@ -12,15 +13,27 @@
|
||||
import { clipboardStore } from "$lib/stores/clipboard";
|
||||
import { shouldHidePaths, maskPaths, showThinkingBlocks } from "$lib/stores/config";
|
||||
|
||||
// Virtual windowing constants — keeps the DOM lean during long sessions
|
||||
const WINDOW_SIZE = 150; // max lines rendered at once
|
||||
const LOAD_CHUNK = 50; // how many older lines to load when scrolling up
|
||||
const AVG_LINE_HEIGHT = 60; // rough px estimate per line, used for top spacer
|
||||
|
||||
let terminalElement: HTMLDivElement;
|
||||
let shouldAutoScroll = true;
|
||||
let lines: TerminalLine[] = [];
|
||||
let currentSearchQuery = "";
|
||||
let currentConversationId: string | null = null;
|
||||
let isRestoringScroll = false;
|
||||
let windowStart = 0;
|
||||
let isLoadingMore = false;
|
||||
let isSwitchingConversation = false;
|
||||
|
||||
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
searchQuery.subscribe((value) => {
|
||||
currentSearchQuery = value;
|
||||
if (searchDebounceTimer) clearTimeout(searchDebounceTimer);
|
||||
searchDebounceTimer = setTimeout(() => {
|
||||
currentSearchQuery = value;
|
||||
}, 150);
|
||||
});
|
||||
|
||||
let hidePaths = false;
|
||||
@@ -48,18 +61,42 @@
|
||||
|
||||
currentConversationId = newId;
|
||||
|
||||
// Restore scroll position for the new conversation after DOM updates
|
||||
// Guard the $: reactive auto-scroll block from firing with stale `lines`
|
||||
// (the old conversation's data) during the switch. Without this, Svelte's
|
||||
// reactive system can re-run the window-advance block before `terminalLines`
|
||||
// has recomputed for the new conversation, overriding our correct windowStart.
|
||||
isSwitchingConversation = true;
|
||||
|
||||
// Read the new conversation's lines directly from the store — the derived
|
||||
// `terminalLines` store (and thus `lines`) may not have recomputed yet when
|
||||
// this subscriber fires, so using `lines` here would give stale data.
|
||||
const newConvLines = get(claudeStore.conversations).get(newId)?.terminalLines ?? [];
|
||||
const savedPosition = claudeStore.getScrollPosition(newId);
|
||||
if (savedPosition === -1) {
|
||||
// Will auto-scroll: pin the window to the tail of the new conversation
|
||||
shouldAutoScroll = true;
|
||||
windowStart = Math.max(0, newConvLines.length - WINDOW_SIZE);
|
||||
} else {
|
||||
// Will restore a specific position: always start from the top of history
|
||||
shouldAutoScroll = false;
|
||||
windowStart = 0;
|
||||
}
|
||||
|
||||
// Block the scroll handler during the entire DOM transition — scroll events
|
||||
// can fire mid-tick when the content changes, and handleScroll would see
|
||||
// scrollTop not at the bottom yet and set shouldAutoScroll = false, breaking
|
||||
// autoscroll for the new conversation permanently.
|
||||
isRestoringScroll = true;
|
||||
|
||||
// Restore scroll position for the new conversation after DOM updates.
|
||||
// Clear the switch guard first so the $: block can react to new lines
|
||||
// arriving after the switch settles.
|
||||
await tick();
|
||||
isSwitchingConversation = false;
|
||||
if (terminalElement) {
|
||||
const savedPosition = claudeStore.getScrollPosition(newId);
|
||||
isRestoringScroll = true;
|
||||
if (savedPosition === -1) {
|
||||
// Auto-scroll to bottom
|
||||
shouldAutoScroll = true;
|
||||
terminalElement.scrollTop = terminalElement.scrollHeight;
|
||||
} else {
|
||||
// Restore to saved position
|
||||
shouldAutoScroll = false;
|
||||
terminalElement.scrollTop = savedPosition;
|
||||
}
|
||||
// Small delay to prevent the scroll handler from overriding our restore
|
||||
@@ -69,10 +106,30 @@
|
||||
}
|
||||
});
|
||||
|
||||
function handleScroll() {
|
||||
async function handleScroll() {
|
||||
if (!terminalElement || isRestoringScroll) return;
|
||||
const { scrollTop, scrollHeight, clientHeight } = terminalElement;
|
||||
shouldAutoScroll = scrollHeight - scrollTop - clientHeight < 100;
|
||||
|
||||
// Load older lines when the user scrolls near the top of the visible window.
|
||||
// Use windowStart * AVG_LINE_HEIGHT (the spacer height) as the baseline so
|
||||
// we trigger at the top of the rendered content, not the absolute container top.
|
||||
if (scrollTop < windowStart * AVG_LINE_HEIGHT + 300 && windowStart > 0 && !isLoadingMore) {
|
||||
isLoadingMore = true;
|
||||
const prevScrollHeight = terminalElement.scrollHeight;
|
||||
const prevScrollTop = terminalElement.scrollTop;
|
||||
|
||||
windowStart = Math.max(0, windowStart - LOAD_CHUNK);
|
||||
|
||||
await tick();
|
||||
|
||||
if (terminalElement) {
|
||||
// Compensate for the new items pushing content down
|
||||
terminalElement.scrollTop =
|
||||
prevScrollTop + (terminalElement.scrollHeight - prevScrollHeight);
|
||||
}
|
||||
isLoadingMore = false;
|
||||
}
|
||||
}
|
||||
|
||||
afterUpdate(() => {
|
||||
@@ -138,6 +195,19 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Visible slice — only render lines within the current window
|
||||
$: visibleLines = lines.slice(windowStart, windowStart + WINDOW_SIZE);
|
||||
|
||||
// Height of the invisible spacer above the visible window
|
||||
$: topSpacerHeight = windowStart * AVG_LINE_HEIGHT;
|
||||
|
||||
// Advance the window forward when auto-scrolling and new lines overflow it.
|
||||
// Skip during conversation switches — `lines` may still hold the previous
|
||||
// conversation's data, which would push windowStart past the new conv's end.
|
||||
$: if (shouldAutoScroll && !isSwitchingConversation && lines.length > windowStart + WINDOW_SIZE) {
|
||||
windowStart = Math.max(0, lines.length - WINDOW_SIZE);
|
||||
}
|
||||
|
||||
$: {
|
||||
if (currentSearchQuery && lines.length > 0) {
|
||||
const escapedQuery = currentSearchQuery.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
@@ -179,6 +249,7 @@
|
||||
if (terminalElement) {
|
||||
terminalElement.removeEventListener("copy", handleCopy);
|
||||
}
|
||||
if (searchDebounceTimer) clearTimeout(searchDebounceTimer);
|
||||
});
|
||||
|
||||
// Copy message content to clipboard
|
||||
@@ -238,7 +309,13 @@
|
||||
Waiting for Claude... Type a message below to start!
|
||||
</div>
|
||||
{:else}
|
||||
{#each lines as line (line.id)}
|
||||
<div style="height: {topSpacerHeight}px" aria-hidden="true"></div>
|
||||
{#if windowStart > 0}
|
||||
<div class="terminal-older-indicator">
|
||||
↑ {windowStart} older {windowStart === 1 ? "message" : "messages"} — scroll up to load
|
||||
</div>
|
||||
{/if}
|
||||
{#each visibleLines as line (line.id)}
|
||||
{#if line.type === "thinking"}
|
||||
{#if showThinking}
|
||||
<ThinkingBlock content={line.content} timestamp={line.timestamp} />
|
||||
@@ -428,6 +505,15 @@
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.terminal-older-indicator {
|
||||
color: var(--text-tertiary, #6b7280);
|
||||
font-size: 0.75rem;
|
||||
text-align: center;
|
||||
padding: 0.25rem 0;
|
||||
margin-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.terminal-header-text {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user