generated from nhcarrigan/template
b745100bd5
## Summary This PR covers the full audit of Claude CLI changes from 2.1.50 to 2.1.53, plus a batch of bug fixes, new features, and maintenance work identified during that review. ### New Features - **Workspace trust gate** — detects hooks, MCP servers, and custom commands in a workspace before connecting; persists trust decisions so users aren't prompted repeatedly - **Custom background image** — users can set a background image with configurable opacity; character panel and compact mode go transparent when active - **Draggable tab reordering** — conversation tabs can be reordered via pointer-event drag-and-drop (HTML5 drag is intercepted by Tauri/WebView2, so pointer events are used instead) - **Org UUID in account info** — exposes the org UUID from Claude auth status ### Bug Fixes - **Unread dot false positives** — initialise unread counts on mount to prevent all tabs showing the blue dot after toggling the file editor (Closes #164) - **Watchdog for hung WSL bridge** — detects connections that never receive `system:init` and kills the stale process after 1 minute (Closes #166) - **Suppress terminal window flash on Windows** — applies `CREATE_NO_WINDOW` to all subprocesses via a `HideWindow` trait extension (Closes #165) - **HTML escaping in markdown renderer** — escape `<` and `>` in `codespan` and `html` renderer callbacks to prevent raw HTML injection (Closes #169) ### Maintenance - Verify stream-JSON handles tool results above the 50K threshold correctly (Closes #162) - Reviewed hook security fixes from CLI 2.1.51 — not applicable to our setup (Closes #163) - Expose org UUID from `claude auth status` (Closes #160) - Clean up Svelte and Vite build warnings (`a11y_click_events_have_key_events`, `state_referenced_locally`, `non_reactive_update`, `codeSplitting`, chunk size, CodeMirror dynamic import) - Update all npm dependencies to latest compatible versions with exact pinning (Closes #81, Closes #82, Closes #83, Closes #84, Closes #85, Closes #86, Closes #87, Closes #90, Closes #91, Closes #93, Closes #94, Closes #95, Closes #96, Closes #97, Closes #98, Closes #99, Closes #101, Closes #141, Closes #142, Closes #143, Closes #145, Closes #146, Closes #147) - Run `cargo update` to bring Cargo.lock up to date ### Closes Closes #160 Closes #162 Closes #163 Closes #164 Closes #165 Closes #166 Closes #167 Closes #168 Closes #169 Closes #81 Closes #82 Closes #83 Closes #84 Closes #85 Closes #86 Closes #87 Closes #90 Closes #91 Closes #93 Closes #94 Closes #95 Closes #96 Closes #97 Closes #98 Closes #99 Closes #101 Closes #141 Closes #142 Closes #143 Closes #145 Closes #146 Closes #147 ✨ This PR was created with help from Hikari~ 🌸 Reviewed-on: #171 Co-authored-by: Hikari <hikari@nhcarrigan.com> Co-committed-by: Hikari <hikari@nhcarrigan.com>
405 lines
13 KiB
Svelte
405 lines
13 KiB
Svelte
<script lang="ts">
|
|
import { claudeStore } from "$lib/stores/claude";
|
|
import { onMount } from "svelte";
|
|
import type { Conversation } from "$lib/stores/conversations";
|
|
import { SvelteMap } from "svelte/reactivity";
|
|
import CloseTabConfirmModal from "./CloseTabConfirmModal.svelte";
|
|
|
|
// Use store subscriptions with $ syntax
|
|
const conversations = $derived(claudeStore.conversations);
|
|
const activeConversationId = $derived(claudeStore.activeConversationId);
|
|
|
|
let editingTabId = $state<string | null>(null);
|
|
let editingName = $state("");
|
|
|
|
// Tab order for pointer-drag reordering
|
|
let tabOrder = $state<string[]>([]);
|
|
let draggedId = $state<string | null>(null);
|
|
let dragOverId = $state<string | null>(null);
|
|
let dragStartX = 0;
|
|
let isDragging = false;
|
|
let wasDragged = false;
|
|
let tabsRef = $state<HTMLElement | null>(null);
|
|
|
|
// Keep tabOrder in sync with conversations map (add new, remove deleted)
|
|
$effect(() => {
|
|
const currentIds = Array.from($conversations.keys());
|
|
const validIds = tabOrder.filter((id) => currentIds.includes(id));
|
|
const newIds = currentIds.filter((id) => !tabOrder.includes(id));
|
|
if (validIds.length !== tabOrder.length || newIds.length > 0) {
|
|
tabOrder = [...validIds, ...newIds];
|
|
}
|
|
});
|
|
|
|
// Track last seen message count for each conversation
|
|
let lastSeenMessageCount = new SvelteMap<string, number>();
|
|
|
|
// Confirmation modal state
|
|
let showConfirmModal = $state(false);
|
|
let tabToClose = $state<string | null>(null);
|
|
let tabToCloseName = $state("");
|
|
|
|
// Update last seen count when active conversation changes
|
|
$effect(() => {
|
|
if ($activeConversationId) {
|
|
const activeConv = $conversations.get($activeConversationId);
|
|
if (activeConv) {
|
|
lastSeenMessageCount.set($activeConversationId, activeConv.terminalLines.length);
|
|
// Trigger reactivity
|
|
lastSeenMessageCount = lastSeenMessageCount;
|
|
}
|
|
}
|
|
});
|
|
|
|
function createNewTab() {
|
|
claudeStore.createConversation();
|
|
}
|
|
|
|
async function switchTab(id: string) {
|
|
if (editingTabId) {
|
|
saveTabName();
|
|
}
|
|
await claudeStore.switchConversation(id);
|
|
|
|
// Mark messages as seen when switching to this tab
|
|
const conv = $conversations.get(id);
|
|
if (conv) {
|
|
lastSeenMessageCount.set(id, conv.terminalLines.length);
|
|
// Trigger reactivity
|
|
lastSeenMessageCount = lastSeenMessageCount;
|
|
}
|
|
}
|
|
|
|
function deleteTab(id: string, event: MouseEvent) {
|
|
event.stopPropagation();
|
|
if ($conversations.size > 1) {
|
|
const conversation = $conversations.get(id);
|
|
if (conversation && conversation.connectionStatus === "connected") {
|
|
// Show confirmation modal for connected tabs
|
|
tabToClose = id;
|
|
tabToCloseName = conversation.name;
|
|
showConfirmModal = true;
|
|
} else {
|
|
// Close disconnected tabs immediately
|
|
claudeStore.deleteConversation(id);
|
|
}
|
|
}
|
|
}
|
|
|
|
function confirmCloseTab() {
|
|
if (tabToClose) {
|
|
claudeStore.deleteConversation(tabToClose);
|
|
}
|
|
showConfirmModal = false;
|
|
tabToClose = null;
|
|
tabToCloseName = "";
|
|
}
|
|
|
|
function cancelCloseTab() {
|
|
showConfirmModal = false;
|
|
tabToClose = null;
|
|
tabToCloseName = "";
|
|
}
|
|
|
|
function startEditing(id: string, name: string, event: MouseEvent) {
|
|
event.stopPropagation();
|
|
editingTabId = id;
|
|
editingName = name;
|
|
// Focus input after DOM update
|
|
setTimeout(() => {
|
|
const input = document.querySelector('.tab-item input[type="text"]') as HTMLInputElement;
|
|
if (input) input.focus();
|
|
}, 0);
|
|
}
|
|
|
|
function saveTabName() {
|
|
if (editingTabId && editingName.trim()) {
|
|
claudeStore.renameConversation(editingTabId, editingName.trim());
|
|
}
|
|
editingTabId = null;
|
|
editingName = "";
|
|
}
|
|
|
|
function getConnectionStatusColor(status: Conversation["connectionStatus"]): string {
|
|
switch (status) {
|
|
case "connected":
|
|
return "bg-green-500";
|
|
case "connecting":
|
|
return "bg-yellow-500";
|
|
case "disconnected":
|
|
return "bg-red-500";
|
|
default:
|
|
return "bg-gray-500";
|
|
}
|
|
}
|
|
|
|
function hasUnreadMessages(id: string, conversation: Conversation): boolean {
|
|
if (id === $activeConversationId) return false; // Active tab never has unread
|
|
const lastSeen = lastSeenMessageCount.get(id) || 0;
|
|
return conversation.terminalLines.length > lastSeen;
|
|
}
|
|
|
|
function handleKeydown(event: KeyboardEvent) {
|
|
if (event.key === "Enter") {
|
|
saveTabName();
|
|
} else if (event.key === "Escape") {
|
|
editingTabId = null;
|
|
editingName = "";
|
|
} else if (event.key === " ") {
|
|
event.stopPropagation();
|
|
}
|
|
}
|
|
|
|
function handleTabKeydown(id: string, event: KeyboardEvent) {
|
|
if (event.key === "Enter" || event.key === " ") {
|
|
event.preventDefault();
|
|
switchTab(id);
|
|
}
|
|
}
|
|
|
|
async function handleTabClick(id: string) {
|
|
if (wasDragged) {
|
|
wasDragged = false;
|
|
return;
|
|
}
|
|
await switchTab(id);
|
|
}
|
|
|
|
function handlePointerDown(event: PointerEvent, id: string) {
|
|
if (editingTabId === id) return;
|
|
draggedId = id;
|
|
dragStartX = event.clientX;
|
|
isDragging = false;
|
|
wasDragged = false;
|
|
|
|
function onMove(e: PointerEvent) {
|
|
if (!isDragging && Math.abs(e.clientX - dragStartX) > 5) {
|
|
isDragging = true;
|
|
}
|
|
if (!isDragging || !tabsRef) return;
|
|
const tabs = tabsRef.querySelectorAll<HTMLElement>("[data-tab-id]");
|
|
dragOverId = null;
|
|
for (const tab of tabs) {
|
|
const rect = tab.getBoundingClientRect();
|
|
if (e.clientX >= rect.left && e.clientX <= rect.right) {
|
|
const tabId = tab.dataset.tabId;
|
|
if (tabId && tabId !== id) {
|
|
dragOverId = tabId;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
function onUp() {
|
|
if (isDragging && draggedId && dragOverId && draggedId !== dragOverId) {
|
|
const order = [...tabOrder];
|
|
const fromIndex = order.indexOf(draggedId);
|
|
const toIndex = order.indexOf(dragOverId);
|
|
order.splice(fromIndex, 1);
|
|
order.splice(toIndex, 0, draggedId);
|
|
tabOrder = order;
|
|
wasDragged = true;
|
|
}
|
|
draggedId = null;
|
|
dragOverId = null;
|
|
isDragging = false;
|
|
window.removeEventListener("pointermove", onMove);
|
|
window.removeEventListener("pointerup", onUp);
|
|
window.removeEventListener("pointercancel", onUp);
|
|
}
|
|
|
|
window.addEventListener("pointermove", onMove);
|
|
window.addEventListener("pointerup", onUp);
|
|
window.addEventListener("pointercancel", onUp);
|
|
}
|
|
|
|
// Keyboard shortcuts
|
|
onMount(() => {
|
|
// Initialise all conversations as seen on mount so that remounting
|
|
// this component (e.g. after closing the file editor) doesn't falsely
|
|
// mark existing messages as unread.
|
|
for (const [id, conversation] of $conversations) {
|
|
lastSeenMessageCount.set(id, conversation.terminalLines.length);
|
|
}
|
|
lastSeenMessageCount = lastSeenMessageCount;
|
|
|
|
function handleGlobalKeydown(event: KeyboardEvent) {
|
|
// Ctrl/Cmd + T: New tab
|
|
if ((event.ctrlKey || event.metaKey) && event.key === "t") {
|
|
event.preventDefault();
|
|
createNewTab();
|
|
}
|
|
// Ctrl/Cmd + W: Close current tab
|
|
else if ((event.ctrlKey || event.metaKey) && event.key === "w") {
|
|
event.preventDefault();
|
|
if ($activeConversationId && $conversations.size > 1) {
|
|
const conversation = $conversations.get($activeConversationId);
|
|
if (conversation && conversation.connectionStatus === "connected") {
|
|
// Show confirmation modal for connected tabs
|
|
tabToClose = $activeConversationId;
|
|
tabToCloseName = conversation.name;
|
|
showConfirmModal = true;
|
|
} else {
|
|
// Close disconnected tabs immediately
|
|
claudeStore.deleteConversation($activeConversationId);
|
|
}
|
|
}
|
|
}
|
|
// Ctrl/Cmd + Tab: Next tab
|
|
else if ((event.ctrlKey || event.metaKey) && event.key === "Tab" && !event.shiftKey) {
|
|
event.preventDefault();
|
|
const currentIndex = tabOrder.findIndex((id) => id === $activeConversationId);
|
|
if (currentIndex !== -1) {
|
|
const nextIndex = (currentIndex + 1) % tabOrder.length;
|
|
claudeStore.switchConversation(tabOrder[nextIndex]);
|
|
}
|
|
}
|
|
// Ctrl/Cmd + Shift + Tab: Previous tab
|
|
else if ((event.ctrlKey || event.metaKey) && event.key === "Tab" && event.shiftKey) {
|
|
event.preventDefault();
|
|
const currentIndex = tabOrder.findIndex((id) => id === $activeConversationId);
|
|
if (currentIndex !== -1) {
|
|
const prevIndex = (currentIndex - 1 + tabOrder.length) % tabOrder.length;
|
|
claudeStore.switchConversation(tabOrder[prevIndex]);
|
|
}
|
|
}
|
|
}
|
|
|
|
window.addEventListener("keydown", handleGlobalKeydown);
|
|
return () => window.removeEventListener("keydown", handleGlobalKeydown);
|
|
});
|
|
</script>
|
|
|
|
<div
|
|
bind:this={tabsRef}
|
|
class="terminal-tabs flex items-center gap-1 px-2 py-1 bg-[var(--bg-secondary)] border-b border-[var(--border-color)]"
|
|
>
|
|
{#each tabOrder
|
|
.filter((id) => $conversations.has(id))
|
|
.map((id) => ({ id, conversation: $conversations.get(id)! })) as { id, conversation } (id)}
|
|
<div
|
|
data-tab-id={id}
|
|
class="tab-item group relative flex items-center px-3 py-1.5 rounded-t transition-all
|
|
{id === $activeConversationId
|
|
? 'bg-[var(--bg-terminal)] text-[var(--text-primary)] border-t border-l border-r border-[var(--border-color)]'
|
|
: 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:bg-[var(--bg-terminal)]/50'}
|
|
{dragOverId === id && draggedId !== id ? 'drag-over' : ''}
|
|
{draggedId === id ? 'dragging' : ''}"
|
|
onpointerdown={(e) => handlePointerDown(e, id)}
|
|
onclick={() => handleTabClick(id)}
|
|
onkeydown={(e) => handleTabKeydown(id, e)}
|
|
role="tab"
|
|
tabindex={0}
|
|
aria-selected={id === $activeConversationId}
|
|
>
|
|
{#if editingTabId === id}
|
|
<input
|
|
type="text"
|
|
bind:value={editingName}
|
|
onblur={saveTabName}
|
|
onkeydown={handleKeydown}
|
|
onclick={(e) => e.stopPropagation()}
|
|
class="bg-transparent border-b border-[var(--border-color)] outline-none px-0 py-0 text-sm w-32 select-text"
|
|
/>
|
|
{:else}
|
|
<div class="flex items-center gap-2">
|
|
<div
|
|
class="w-2 h-2 rounded-full {getConnectionStatusColor(conversation.connectionStatus)}"
|
|
title="Connection: {conversation.connectionStatus}"
|
|
></div>
|
|
<span
|
|
class="text-sm pr-2 max-w-[150px] truncate"
|
|
ondblclick={(e) => startEditing(id, conversation.name, e)}
|
|
role="button"
|
|
tabindex={-1}
|
|
>
|
|
{conversation.name}
|
|
</span>
|
|
{#if hasUnreadMessages(id, conversation)}
|
|
<div
|
|
class="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-blue-500 animate-pulse pointer-events-none"
|
|
title="New messages"
|
|
></div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
<div
|
|
class="absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity"
|
|
>
|
|
{#if $conversations.size > 1}
|
|
<button
|
|
onclick={(e) => deleteTab(id, e)}
|
|
class="w-4 h-4 flex items-center justify-center rounded hover:bg-[var(--bg-secondary)]"
|
|
title="Close tab"
|
|
>
|
|
<svg
|
|
class="w-3 h-3"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M6 18L18 6M6 6l12 12"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
|
|
<button
|
|
onclick={createNewTab}
|
|
class="new-tab-btn flex items-center justify-center w-7 h-7 rounded hover:bg-[var(--bg-tertiary)] text-[var(--text-secondary)] transition-colors"
|
|
title="New conversation (Ctrl+T)"
|
|
>
|
|
<svg
|
|
class="w-4 h-4"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
>
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<CloseTabConfirmModal
|
|
isOpen={showConfirmModal}
|
|
tabName={tabToCloseName}
|
|
onConfirm={confirmCloseTab}
|
|
onCancel={cancelCloseTab}
|
|
/>
|
|
|
|
<style>
|
|
.terminal-tabs {
|
|
min-height: 36px;
|
|
}
|
|
|
|
.tab-item {
|
|
min-width: 100px;
|
|
cursor: grab;
|
|
touch-action: none;
|
|
user-select: none;
|
|
}
|
|
|
|
.tab-item:active {
|
|
cursor: grabbing;
|
|
}
|
|
|
|
.drag-over {
|
|
border-left: 2px solid var(--accent-primary);
|
|
}
|
|
|
|
.dragging {
|
|
opacity: 0.4;
|
|
}
|
|
</style>
|